Start of API guide + some whitespace removal.

This commit is contained in:
Michael DeHaan 2013-03-24 15:54:57 -04:00
parent 175f3c23da
commit 44372ed191
3 changed files with 374 additions and 69 deletions

15
TODO.md
View File

@ -6,7 +6,7 @@ TODO items for ansible commander
NEXT STEPS
* Michael -- flesh out REST model & tests for everything, API guide
* Chris ---- document celery devs stuff.
* Chris ---- document celery devs stuff.
* Chris ---- inventory script is done, integrate with celery system, make celery REST triggerable, callback plugin
* Michael -- enhance callback plugin to provide runner and playbook context
@ -14,23 +14,18 @@ LATER
-----
* UI layer
* CLI client
* CLI client (and libs)
* clean up initial migrations
* init scripts, Apache proxying, etc
* does inventory script need any caching
* credentials subsystem -- let app hold on to keys for user
TWEAKS
------
TWEAKS/ASSORTED
---------------
* add a synthetic bit to the organization to indicate if the current user is an administator of it
* add related resources to the user object to they can quickly find organizations they are an admin of, organizations, teams, projects, etc
ASSORTED
-------
* uniqueness checks for playbook paths?
* allow multiple playbook execution types per project, different tag choices (Projects/Runnables?)
* allow multiple playbook execution types per project, different --tag choices, different --limit choices (maybe just free form in the job for now?)
* permissions infrastructure about who can kick off what kind of jobs

310
docs/api_guide.md Normal file
View File

@ -0,0 +1,310 @@
API Guide
=========
This document describes how to do nearly everything in Ansible commander via the REST API.
To begin, you should have set up Ansible Commander (see SETUP.md) and created at least one Django super user.
To do this from the development project, run "make adduser".
For the purposes of this guide, we will assume the superuser username and password is admin/admin.
Conventions
===========
* Ansible Commander uses a standard REST API, rooted at /api/v1/ on the server.
* All data is JSON. You may have to specify the content/type on POST or PUT requests accordingly.
* All URIs should end in "/" or you will get a 301 redirect.
Time to get started.
Aside: Django Admin
===================
When working with REST objects it is often helpful to use the Django admin interface to pre-create objects,
and then retrieve collections of objects with GET request to look at their structure. This will help
users understand how to create new objects as well as how to edit them.
Aside: Read Only Fields
=======================
Certain fields in the REST API are marked read only. These usually include the URL of a resource, the ID,
and occasionally some internal fields. For instance, the 'created_by' attribute of each object indicates
which user created the resource, and can not be edited.
Working With Users
==================
After a fresh install, there should be no organizations and only the superuser.
Since we're explaining our first resource, we'll be a bit more verbose with the user
information than with some other REST resources.
Listing Users
-------------
We will pay special attention to this first access request, as any list view looks basically
the same.
curl http://admin:admin@127.0.0.1:8013/api/v1/users/
{
"count": 1, "next": null, "previous": null,
"results": [
{
"url": "/api/v1/users/1/",
"id": 1, "username": "admin",
"first_name": "",
"last_name": "",
"email": "root@localhost.localdomain",
"is_active": true,
"is_superuser": true,
"related": {
"admin_of_organizations": "/api/v1/users/1/admin_of_organizations/",
"organizations": "/api/v1/users/1/organizations/",
"teams": "/api/v1/users/1/teams/"
}
}
]
}
Notice that the requests are paginated, so you may have to access additional URLs if 'previous' and 'next'
are set to access the full collection.
You will also see 'related' URLs on each object, which tell you from any point in the API what resources
you can get to from a particular resource.
Adding Users
------------
To add a user, just POST to the users collection:
curl -X POST -d @input http://admin:admin@127.0.0.1:8013/api/v1/users/
{
"username" : "foo", "password: "foo"
}
And optionally specify other fields. Super users or organization admins can create users. We'll learn more about
organizations shortly!
Getting a User
--------------
As you would expect, a user, superuser, or organization admin can retrieve the record of a user directly via:
curl http://admin:admin@127.0.0.1/api/v1/users/1/
And recieves something very much like this:
{
"url": "/api/v1/users/1/",
"id": 1, "username": "admin",
"first_name": "",
"last_name": "",
"email": "root@localhost.localdomain",
"is_active": true,
"is_superuser": true,
"related": {
"admin_of_organizations": "/api/v1/users/1/admin_of_organizations/",
"organizations": "/api/v1/users/1/organizations/",
"teams": "/api/v1/users/1/teams/"
}
}
Deleting Users
--------------
In ansible commander, nothing is truly deleted, objects are only set inactive and renamed. This allows recovery
of those objects using the Django administrative interface or the database command line.
To delete an object, just DELETE to a resource, like so:
curl -X DELETE http://admin:admin@127.0.0.1:8013/api/v1/users/2/
Only a user's organization admins, or a superuser, can mark a user as deleted.
Updating Users
--------------
A user may update only his password, and an organization admin or superuser can update a lot more about the
user. To update a user, just PUT to the resource, like so:
curl -X PUT -d @input http://admin:admin@127.0.0.1:8013/api/v1/users/2/
{
'password': 'new_password'
}
Organizations
=============
Organizations are the basic unit of multi-tenancy and access in Ansible Commander.
At the basic level, when a user is created, he should then be added to one or more organziations.
These may represent concepts like "Engineering" or "Finance".
Under each organization, there will also be multiple teams, like "Engineering QA Team", or "Engineering Release Team".
Organizations also have admin users (which can be assigned by the super user or other organization admins) whom also
have most of the powers of a superuser to users inside that organization.
Listing Organizations
---------------------
GET /api/v1/organizations/
Editing Organizations
---------------------
PUT /api/v1/organizations/1/
Adding users to an organization
-------------------------------
POST the data you recieved from GET /api/v1/users/X/ to /api/v1/organizations/Y/organizations/
Similarly, you can list users of an organization if you are an org admin or superuser with:
GET /api/v1/organizations/X/users/
Adding admins to an organization
--------------------------------
POST the data you recieved from GET /api/v1/users/X/ to /api/v1/organizations/Y/admins/
Similarly, you can list admins of an organization if you are an org admin or superuser, with:
GET /api/v1/organizations/X/admins/
Note that adding a user as an admin does not automatically add them as a regular organization user. If the user
is to be a member of any project teams, add them both to the admin list and the users list for a given organization.
Adding projects to an organization
----------------------------------
An organization may also have projects, which would be a concept like "ACME Corp Application" or "Application Foo".
Projects are covered in a later section, but are added to organizations, once created, by posting the record
recieved from /api/v1/projects/X/ to /api/v1/organizations/Y/.
Removing users, admins, and projects from organizations
-------------------------------------------------------
Deleting from sub-collections in Ansible Commander is not handled by a DELETE verb, but by a modified POST.
To remove a user ID X from an organization Y, simply POST the following to the collection /api/v1/organizations/Y/users/:
{ 'id' : X, 'disassociate' : 1 }
A similar post can also be made to /api/v1/organizations/Y/admins/ to remove an admin user or
/api/v1/organizations/Y/projects/.
The same removal facility is available elsewhere in Ansible commander for most related resources.
Projects
========
...
Teams
=====
...
Inventories
===========
...
Adding Inventories
------------------
Adding Hosts
------------
Adding Groups
-------------
Editing Hosts or Groups
-----------------------
Adding Host Variables
---------------------
Adding Group Variables
----------------------
Editing Host or Group Variables
-------------------------------
Removing Host or Group Variables
--------------------------------
Adding Sub Groups
-----------------
Removing Sub Groups
-------------------
Access Via Inventory Script
---------------------------
Credentials
===========
Creating credentials
--------------------
Adding credentials to users, teams, or projects
-----------------------------------------------
Seeing what credentials a user has access to
--------------------------------------------
Launch Jobs
===========
Submitting A Launch Job
-----------------------
Getting Job Results
-------------------
Browsing Log Data
-----------------
Tags
====
Adding Tags
-----------
Finding Tagged Resources
------------------------
Editing/Removing Tags
---------------------
Permissions
===========
Permissions can be granted to users or teams and indicate they are allowed to use
certain combinations of credentials, inventories to deploy to certain projects.
For instance, a permission may define that the QA Team can use certain credentials to push
to a Test Inventory, while another permission may indicate the Release Engineering team can
push a different project with different credentials to a Production inventory source.
Audit Trails
============
Viewing object audit trails
---------------------------

View File

@ -37,10 +37,10 @@ class EditHelper(object):
def illegal_changes(cls, request, obj, model_class):
''' have any illegal changes been made (for a PUT request)? '''
can_admin = model_class.can_user_administrate(request.user, obj)
if (not can_admin) or (can_admin == 'partial'):
if (not can_admin) or (can_admin == 'partial'):
check_fields = model_class.admin_only_edit_fields
changed = cls.fields_changed(check_fields, obj, request.DATA)
if len(changed.keys()) > 0:
if len(changed.keys()) > 0:
return True
return False
@ -49,7 +49,7 @@ class EditHelper(object):
''' return the fields that would be changed by a prospective PUT operation '''
changed = {}
for f in fields:
left = getattr(obj, f, None)
left = getattr(obj, f, None)
if left is None:
raise Exception("internal error, %s is not a member of %s" % (f, obj))
right = data.get(f, None)
@ -74,11 +74,11 @@ class UserHelper(object):
@classmethod
def can_user_read(cls, user, obj):
''' a user can be read if they are on the same team or can be administrated '''
''' a user can be read if they are on the same team or can be administrated '''
matching_teams = user.teams.filter(users__in = [ user ]).count()
return matching_teams or cls.can_user_administrate(user, obj)
@classmethod
@classmethod
def can_user_delete(cls, user, obj):
if user.is_superuser:
return True
@ -87,8 +87,8 @@ class UserHelper(object):
class CommonModel(models.Model):
'''
common model for all object types that have these standard fields
'''
common model for all object types that have these standard fields
'''
class Meta:
@ -98,23 +98,23 @@ class CommonModel(models.Model):
description = models.TextField(blank=True, default='')
created_by = models.ForeignKey('auth.User', on_delete=SET_NULL, null=True, related_name='%s(class)s_created') # not blank=False on purpose for admin!
creation_date = models.DateField(auto_now_add=True)
tags = models.ManyToManyField('Tag', related_name='%(class)s_by_tag', blank=True)
tags = models.ManyToManyField('Tag', related_name='%(class)s_by_tag', blank=True)
audit_trail = models.ManyToManyField('AuditTrail', related_name='%(class)s_by_audit_trail', blank=True)
active = models.BooleanField(default=True)
def __unicode__(self):
return unicode(self.name)
@classmethod
@classmethod
def can_user_administrate(cls, user, obj):
# FIXME: do we want a seperate method to override put? This is kind of general purpose
raise exceptions.NotImplementedError()
@classmethod
@classmethod
def can_user_delete(cls, user, obj):
raise exceptions.NotImplementedError()
@classmethod
@classmethod
def can_user_read(cls, user, obj):
raise exceptions.NotImplementedError()
@ -130,16 +130,16 @@ class CommonModel(models.Model):
return False
rc = cls.can_user_administrate(user, obj)
return rc
@classmethod
def can_user_unattach(cls, user, obj, sub_obj, relationship):
return cls.can_user_administrate(user, obj)
class Tag(models.Model):
'''
any type of object can be given a search tag
'''
any type of object can be given a search tag
'''
class Meta:
app_label = 'main'
@ -162,15 +162,15 @@ class Tag(models.Model):
# anybody can read tags, we won't show much detail other than the names
return True
class AuditTrail(models.Model):
'''
changing any object records the change
'''
changing any object records the change
'''
class Meta:
app_label = 'main'
resource_type = models.CharField(max_length=64)
modified_by = models.ForeignKey('auth.User', on_delete=SET_NULL, null=True, blank=True)
delta = models.TextField() # FIXME: switch to JSONField
@ -181,10 +181,10 @@ class AuditTrail(models.Model):
tag = models.ForeignKey('Tag', on_delete=SET_NULL, null=True, blank=True)
class Organization(CommonModel):
'''
organizations are the basic unit of multi-tenancy divisions
'''
'''
organizations are the basic unit of multi-tenancy divisions
'''
class Meta:
app_label = 'main'
@ -196,11 +196,11 @@ class Organization(CommonModel):
import lib.urls
return reverse(lib.urls.views_OrganizationsDetail, args=(self.pk,))
@classmethod
@classmethod
def can_user_delete(cls, user, obj):
return user in obj.admins.all()
@classmethod
@classmethod
def can_user_administrate(cls, user, obj):
# FIXME: super user checks should be higher up so we don't have to repeat them
if user.is_superuser:
@ -210,11 +210,11 @@ class Organization(CommonModel):
rc = user in obj.admins.all()
return rc
@classmethod
@classmethod
def can_user_read(cls, user, obj):
return cls.can_user_administrate(user,obj) or user in obj.users.all()
@classmethod
@classmethod
def can_user_delete(cls, user, obj):
return cls.can_user_administrate(user, obj)
@ -222,15 +222,15 @@ class Organization(CommonModel):
return self.name
class Inventory(CommonModel):
'''
'''
an inventory source contains lists and hosts.
'''
class Meta:
app_label = 'main'
verbose_name_plural = _('inventories')
organization = models.ForeignKey(Organization, null=True, on_delete=SET_NULL, related_name='inventories')
organization = models.ForeignKey(Organization, null=True, on_delete=SET_NULL, related_name='inventories')
def __unicode__(self):
if self.organization:
@ -242,7 +242,7 @@ class Host(CommonModel):
'''
A managed node
'''
class Meta:
app_label = 'main'
@ -255,7 +255,7 @@ class Group(CommonModel):
'''
A group of managed nodes. May belong to multiple groups
'''
class Meta:
app_label = 'main'
@ -272,8 +272,8 @@ class Group(CommonModel):
class VariableData(CommonModel):
'''
A set of host or group variables
'''
'''
class Meta:
app_label = 'main'
verbose_name_plural = _('variable data')
@ -291,7 +291,7 @@ class Credential(CommonModel):
Usually this is a SSH key location, and possibly an unlock password.
If used with sudo, a sudo password should be set if required.
'''
class Meta:
app_label = 'main'
@ -304,25 +304,25 @@ class Credential(CommonModel):
ssh_key_unlock = models.CharField(blank=True, default='', max_length=1024)
ssh_password = models.CharField(blank=True, default='', max_length=1024)
sudo_password = models.CharField(blank=True, default='', max_length=1024)
class Team(CommonModel):
'''
A team is a group of users that work on common projects.
'''
class Meta:
app_label = 'main'
projects = models.ManyToManyField('Project', blank=True, related_name='teams')
users = models.ManyToManyField('auth.User', blank=True, related_name='teams')
organizations = models.ManyToManyField('Organization', related_name='teams')
class Project(CommonModel):
'''
'''
A project represents a playbook git repo that can access a set of inventories
'''
'''
inventories = models.ManyToManyField('Inventory', blank=True, related_name='projects')
local_repository = models.CharField(max_length=1024)
scm_type = models.CharField(max_length=64)
@ -332,7 +332,7 @@ class Project(CommonModel):
import lib.urls
return reverse(lib.urls.views_ProjectsDetail, args=(self.pk,))
@classmethod
@classmethod
def can_user_administrate(cls, user, obj):
if user.is_superuser:
return True
@ -357,7 +357,7 @@ class Permission(CommonModel):
'''
A permission allows a user, project, or team to be able to use an inventory source.
'''
class Meta:
app_label = 'main'
@ -369,10 +369,10 @@ class Permission(CommonModel):
# TODO: other job types (later)
class LaunchJob(CommonModel):
'''
a launch job is a request to apply a project to an inventory source with a given credential
'''
a launch job is a request to apply a project to an inventory source with a given credential
'''
class Meta:
app_label = 'main'
@ -387,18 +387,18 @@ class LaunchJob(CommonModel):
return run_launch_job.delay(self.pk)
# project has one default playbook but really should have a list of playbooks and flags ...
# ENOUGH_TO_RUN_DJANGO=foo ACOM_INVENTORY_ID=<pk> ansible-playbook <path to project selected playbook.yml> -i ansible-commander-inventory.py
# ENOUGH_TO_RUN_DJANGO=foo ACOM_INVENTORY_ID=<pk> ansible-playbook <path to project selected playbook.yml> -i ansible-commander-inventory.py
# ^-- this is a hard coded path
# ssh-agent bash
# ssh-add ... < key entry
#
# inventory script I can write, and will use ACOM_INVENTORY_ID
#
#
# playbook in source control is already on the disk
#
# playbook in source control is already on the disk
# job_type:
# run, check -- enough for now, more initially
# if check, add "--check" to parameters
@ -408,7 +408,7 @@ class LaunchJob(CommonModel):
# self.context.runner
# and the callback will read the environment for ACOM_CELERY_JOB_ID or similar
# and log tons into the database
# we'll also log stdout/stderr somewhere for debugging
# the ansible commander setup instructions will include installing the database logging callback
@ -423,7 +423,7 @@ class LaunchJob(CommonModel):
# TODO: Events
class LaunchJobStatus(CommonModel):
class Meta:
app_label = 'main'
verbose_name_plural = _('launch job statuses')
@ -431,7 +431,7 @@ class LaunchJobStatus(CommonModel):
launch_job = models.ForeignKey('LaunchJob', null=True, on_delete=SET_NULL, related_name='launch_job_statuses')
status = models.IntegerField()
result_data = models.TextField()
# TODO: reporting (MPD)