1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-02 18:21:12 +03:00
awx/lib/main/models/__init__.py

1301 lines
44 KiB
Python

# Copyright (c) 2013 AnsibleWorks, Inc.
#
# This file is part of Ansible Commander.
#
# Ansible Commander is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# Ansible Commander is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible Commander. If not, see <http://www.gnu.org/licenses/>.
import os
from django.conf import settings
from django.db import models, DatabaseError
from django.db.models import CASCADE, SET_NULL, PROTECT
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from django.utils.timezone import now
import exceptions
from jsonfield import JSONField
from djcelery.models import TaskMeta
from rest_framework.authtoken.models import Token
import yaml
# TODO: reporting model TBD
PERM_INVENTORY_ADMIN = 'admin'
PERM_INVENTORY_READ = 'read'
PERM_INVENTORY_WRITE = 'write'
PERM_INVENTORY_DEPLOY = 'run'
PERM_INVENTORY_CHECK = 'check'
JOB_TYPE_CHOICES = [
(PERM_INVENTORY_DEPLOY, _('Run')),
(PERM_INVENTORY_CHECK, _('Check')),
]
PERMISSION_TYPES = [
PERM_INVENTORY_ADMIN,
PERM_INVENTORY_READ,
PERM_INVENTORY_WRITE,
PERM_INVENTORY_DEPLOY,
PERM_INVENTORY_CHECK,
]
PERMISSION_TYPES_ALLOWING_INVENTORY_READ = [
PERM_INVENTORY_ADMIN,
PERM_INVENTORY_WRITE,
PERM_INVENTORY_READ,
]
PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE = [
PERM_INVENTORY_ADMIN,
PERM_INVENTORY_WRITE,
]
PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN = [
PERM_INVENTORY_ADMIN,
]
# FIXME: TODO: make sure all of these are used and consistent
PERMISSION_TYPE_CHOICES = [
(PERM_INVENTORY_READ, _('Read Inventory')),
(PERM_INVENTORY_WRITE, _('Edit Inventory')),
(PERM_INVENTORY_ADMIN, _('Administrate Inventory')),
(PERM_INVENTORY_DEPLOY, _('Deploy To Inventory')),
(PERM_INVENTORY_CHECK, _('Deploy To Inventory (Dry Run)')),
]
class EditHelper(object):
@classmethod
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, request.DATA)
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:
return True
return False
@classmethod
def fields_changed(cls, fields, obj, data):
''' return the fields that would be changed by a prospective PUT operation '''
changed = {}
for f in fields:
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)
if (right is not None) and (left != right):
changed[f] = (left, right)
return changed
class UserHelper(object):
# fields that the user themselves cannot edit, but are not actually read only
admin_only_edit_fields = ('last_name', 'first_name', 'username', 'is_active', 'is_superuser')
@classmethod
def can_user_administrate(cls, user, obj, data):
''' a user can be administrated if they are themselves, or by org admins or superusers '''
if user == obj:
return 'partial'
if user.is_superuser:
return True
matching_orgs = obj.organizations.filter(admins__in = [user]).count()
return matching_orgs
@classmethod
def can_user_read(cls, user, obj):
''' 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, None)
@classmethod
def can_user_delete(cls, user, obj):
if user.is_superuser:
return True
matching_orgs = obj.organizations.filter(admins__in = [user]).count()
return matching_orgs
@classmethod
def can_user_add(cls, user, data):
# TODO: reuse. make helper functions like "is user an org admin"
# apply throughout permissions code
if user.is_superuser:
return True
return user.admin_of_organizations.count() > 0
@classmethod
def can_user_attach(cls, user, obj, sub_obj, relationship_type, data):
if type(sub_obj) != User:
if not sub_obj.can_user_read(user, sub_obj):
return False
rc = cls.can_user_administrate(user, obj, None)
if not rc:
return False
return sub_obj.__class__.can_user_read(user, sub_obj)
@classmethod
def can_user_unattach(cls, user, obj, sub_obj, relationship_type):
return cls.can_user_administrate(user, obj, None)
class PrimordialModel(models.Model):
'''
common model for all object types that have these standard fields
must use a subclass CommonModel or CommonModelNameNotUnique though
as this lacks a name field.
'''
class Meta:
abstract = True
description = models.TextField(blank=True, default='')
created_by = models.ForeignKey('auth.User', on_delete=SET_NULL, null=True, related_name='%s(class)s_created', editable=False) # 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)
audit_trail = models.ManyToManyField('AuditTrail', related_name='%(class)s_by_audit_trail', blank=True)
active = models.BooleanField(default=True)
def __unicode__(self):
return unicode("%s-%s"% (self.name, self.id))
@classmethod
def can_user_administrate(cls, user, obj, data):
# FIXME: do we want a seperate method to override put? This is kind of general purpose
raise Exception("can_user_administrate needs to be implemented in model subclass")
@classmethod
def can_user_delete(cls, user, obj):
raise Exception("can_user_delete needs to be implemented in model subclass")
@classmethod
def can_user_read(cls, user, obj):
raise Exception("can_user_read needs to be implemented in model subclass")
@classmethod
def can_user_add(cls, user, data):
return user.is_superuser
@classmethod
def can_user_attach(cls, user, obj, sub_obj, relationship_type, data):
''' whether you can add sub_obj to obj using the relationship type in a subobject view '''
if type(sub_obj) != User:
if not sub_obj.can_user_read(user, sub_obj):
return False
rc = cls.can_user_administrate(user, obj, None)
if not rc:
return False
# in order to attach something you also be able to read what you are attaching
if type(sub_obj) == User:
# we already check that the user is an admin or org admin up in base_views.py
# because the user doesn't have the attributes on it directly to tie it to the org
return True
else:
return sub_obj.__class__.can_user_read(user, sub_obj)
@classmethod
def can_user_unattach(cls, user, obj, sub_obj, relationship):
return cls.can_user_administrate(user, obj, None)
class CommonModel(PrimordialModel):
''' a base model where the name is unique '''
class Meta:
abstract = True
name = models.CharField(max_length=512, unique=True)
class CommonModelNameNotUnique(PrimordialModel):
''' a base model where the name is not unique '''
class Meta:
abstract = True
name = models.CharField(max_length=512, unique=False)
class Tag(models.Model):
'''
any type of object can be given a search tag
'''
class Meta:
app_label = 'main'
name = models.CharField(max_length=512)
def __unicode__(self):
return unicode(self.name)
def get_absolute_url(self):
return reverse('main:tags_detail', args=(self.pk,))
@classmethod
def can_user_add(cls, user, data):
# anybody can make up tags
return True
@classmethod
def can_user_read(cls, user, obj):
# 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
'''
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
detail = models.TextField()
comment = models.TextField()
# FIXME: this looks like this should be a ManyToMany
tag = models.ForeignKey('Tag', on_delete=SET_NULL, null=True, blank=True)
class Organization(CommonModel):
'''
organizations are the basic unit of multi-tenancy divisions
'''
class Meta:
app_label = 'main'
users = models.ManyToManyField('auth.User', blank=True, related_name='organizations')
admins = models.ManyToManyField('auth.User', blank=True, related_name='admin_of_organizations')
projects = models.ManyToManyField('Project', blank=True, related_name='organizations')
def get_absolute_url(self):
return reverse('main:organizations_detail', args=(self.pk,))
@classmethod
def can_user_delete(cls, user, obj):
return user in obj.admins.all()
@classmethod
def can_user_administrate(cls, user, obj, data):
# FIXME: super user checks should be higher up so we don't have to repeat them
if user.is_superuser:
return True
if obj.created_by == user:
return True
rc = user in obj.admins.all()
return rc
@classmethod
def can_user_read(cls, user, obj):
return cls.can_user_administrate(user,obj,None) or user in obj.users.all()
@classmethod
def can_user_delete(cls, user, obj):
return cls.can_user_administrate(user, obj, None)
def __unicode__(self):
return self.name
class Inventory(CommonModel):
'''
an inventory source contains lists and hosts.
'''
class Meta:
app_label = 'main'
verbose_name_plural = _('inventories')
unique_together = (("name", "organization"),)
organization = models.ForeignKey(Organization, null=False, related_name='inventories')
def get_absolute_url(self):
return reverse('main:inventory_detail', args=(self.pk,))
@classmethod
def _has_permission_types(cls, user, obj, allowed):
if user.is_superuser:
return True
by_org_admin = obj.organization.admins.filter(pk = user.pk).count()
by_team_permission = obj.permissions.filter(
team__in = user.teams.all(),
permission_type__in = allowed
).count()
by_user_permission = obj.permissions.filter(
user = user,
permission_type__in = allowed
).count()
result = (by_org_admin + by_team_permission + by_user_permission)
return result > 0
@classmethod
def _has_any_inventory_permission_types(cls, user, allowed):
'''
rather than checking for a permission on a specific inventory, return whether we have
permissions on any inventory. This is primarily used to decide if the user can create
host or group objects
'''
if user.is_superuser:
return True
by_org_admin = user.organizations.filter(
admins__in = [ user ]
).count()
by_team_permission = Permission.objects.filter(
team__in = user.teams.all(),
permission_type__in = allowed
).count()
by_user_permission = user.permissions.filter(
permission_type__in = allowed
).count()
result = (by_org_admin + by_team_permission + by_user_permission)
return result > 0
@classmethod
def can_user_add(cls, user, data):
if not 'organization' in data:
return True
if user.is_superuser:
return True
if not user.is_superuser:
org = Organization.objects.get(pk=data['organization'])
if user in org.admins.all():
return True
return False
@classmethod
def can_user_administrate(cls, user, obj, data):
return cls._has_permission_types(user, obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN)
@classmethod
def can_user_attach(cls, user, obj, sub_obj, relationship_type, data):
''' whether you can add sub_obj to obj using the relationship type in a subobject view '''
if not sub_obj.can_user_read(user, sub_obj):
return False
return cls._has_permission_types(user, obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE)
@classmethod
def can_user_unattach(cls, user, obj, sub_obj, relationship):
return cls._has_permission_types(user, obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE)
@classmethod
def can_user_read(cls, user, obj):
return cls._has_permission_types(user, obj, PERMISSION_TYPES_ALLOWING_INVENTORY_READ)
@classmethod
def can_user_delete(cls, user, obj):
return cls._has_permission_types(user, obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN)
class Host(CommonModelNameNotUnique):
'''
A managed node
'''
class Meta:
app_label = 'main'
unique_together = (("name", "inventory"),)
variable_data = models.OneToOneField('VariableData', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='host')
inventory = models.ForeignKey('Inventory', null=False, related_name='hosts')
def __unicode__(self):
return self.name
@classmethod
def can_user_read(cls, user, obj):
return Inventory.can_user_read(user, obj.inventory)
@classmethod
def can_user_add(cls, user, data):
if not 'inventory' in data:
return False
inventory = Inventory.objects.get(pk=data['inventory'])
rc = Inventory._has_permission_types(user, inventory, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE)
return rc
def get_absolute_url(self):
return reverse('main:hosts_detail', args=(self.pk,))
# Use .job_host_summaries.all() to get jobs affecting this host.
# Use .job_events.all() to get events affecting this host.
# Use .job_host_summaries.order_by('-pk')[0] to get the last result.
class Group(CommonModelNameNotUnique):
'''
A group of managed nodes. May belong to multiple groups
'''
class Meta:
app_label = 'main'
unique_together = (("name", "inventory"),)
inventory = models.ForeignKey('Inventory', null=False, related_name='groups')
parents = models.ManyToManyField('self', symmetrical=False, related_name='children', blank=True)
variable_data = models.OneToOneField('VariableData', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='group')
hosts = models.ManyToManyField('Host', related_name='groups', blank=True)
def __unicode__(self):
return self.name
@classmethod
def can_user_add(cls, user, data):
if not 'inventory' in data:
return False
inventory = Inventory.objects.get(pk=data['inventory'])
return Inventory._has_permission_types(user, inventory, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE)
@classmethod
def can_user_administrate(cls, user, obj, data):
# here this controls whether the user can attach subgroups
return Inventory._has_permission_types(user, obj.inventory, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE)
@classmethod
def can_user_read(cls, user, obj):
return Inventory.can_user_read(user, obj.inventory)
def get_absolute_url(self):
return reverse('main:groups_detail', args=(self.pk,))
# FIXME: audit nullables
# FIXME: audit cascades
class VariableData(CommonModelNameNotUnique):
'''
A set of host or group variables
'''
class Meta:
app_label = 'main'
verbose_name_plural = _('variable data')
#host = models.OneToOneField('Host', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='variable_data')
#group = models.OneToOneField('Group', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='variable_data')
data = models.TextField() # FIXME: JsonField
def __unicode__(self):
return '%s = %s' % (self.name, self.data)
def get_absolute_url(self):
return reverse('main:variable_detail', args=(self.pk,))
@classmethod
def can_user_read(cls, user, obj):
''' a user can be read if they are on the same team or can be administrated '''
if obj.host is not None:
return Inventory.can_user_read(user, obj.host.inventory)
if obj.group is not None:
return Inventory.can_user_read(user, obj.group.inventory)
return False
class Credential(CommonModelNameNotUnique):
'''
A credential contains information about how to talk to a remote set of hosts
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'
user = models.ForeignKey('auth.User', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='credentials')
team = models.ForeignKey('Team', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='credentials')
ssh_username = models.CharField(
blank=True,
default='',
max_length=1024,
verbose_name=_('SSH username'),
help_text=_('SSH username for a job using this credential.'),
)
ssh_password = models.CharField(
blank=True,
default='',
max_length=1024,
verbose_name=_('SSH password'),
help_text=_('SSH password (or "ASK" to prompt the user).'),
)
ssh_key_data = models.TextField(
blank=True,
default='',
verbose_name=_('SSH private key'),
help_text=_('RSA or DSA private key to be used instead of password.'),
)
ssh_key_unlock = models.CharField(
max_length=1024,
blank=True,
default='',
verbose_name=_('SSH key unlock'),
help_text=_('Passphrase to unlock SSH private key if encrypted (or '
'"ASK" to prompt the user).'),
)
sudo_username = models.CharField(
max_length=1024,
blank=True,
default='',
help_text=_('Sudo username for a job using this credential.'),
)
sudo_password = models.CharField(
max_length=1024,
blank=True,
default='',
help_text=_('Sudo password (or "ASK" to prompt the user).'),
)
@property
def needs_ssh_password(self):
return not self.ssh_key_data and self.ssh_password == 'ASK'
@property
def needs_ssh_key_unlock(self):
return 'ENCRYPTED' in self.ssh_key_data and \
(not self.ssh_key_unlock or self.ssh_key_unlock == 'ASK')
@property
def needs_sudo_password(self):
return self.sudo_password == 'ASK'
@classmethod
def can_user_administrate(cls, user, obj, data):
if user.is_superuser:
return True
if user == obj.user:
return True
if obj.user:
if (obj.user.organizations.filter(admins__in = [user]).count()):
return True
if obj.team:
if user in obj.team.organization.admins.all():
return True
return False
@classmethod
def can_user_delete(cls, user, obj):
if obj.user is None and obj.team is None:
# unassociated credentials may be marked deleted by anyone
return True
return cls.can_user_administrate(user,obj,None)
@classmethod
def can_user_read(cls, user, obj):
''' a user can be read if they are on the same team or can be administrated '''
return cls.can_user_administrate(user, obj, None)
@classmethod
def can_user_add(cls, user, data):
if user.is_superuser:
return True
if 'user' in data:
user_obj = User.objects.get(pk=data['user'])
return UserHelper.can_user_administrate(user, user_obj, data)
if 'team' in data:
team_obj = Team.objects.get(pk=data['team'])
return Team.can_user_administrate(user, team_obj, data)
def get_absolute_url(self):
return reverse('main:credentials_detail', args=(self.pk,))
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')
organization = models.ForeignKey('Organization', blank=False, null=True, on_delete=SET_NULL, related_name='teams')
def get_absolute_url(self):
return reverse('main:teams_detail', args=(self.pk,))
@classmethod
def can_user_administrate(cls, user, obj, data):
# FIXME -- audit when this is called explicitly, if any
if user.is_superuser:
return True
if user in obj.organization.admins.all():
return True
return False
@classmethod
def can_user_read(cls, user, obj):
if cls.can_user_administrate(user, obj, None):
return True
if obj.users.filter(pk__in = [ user.pk ]).count():
return True
return False
@classmethod
def can_user_add(cls, user, data):
if user.is_superuser:
return True
if Organization.objects.filter(admins__in = [user]).count():
# team assignment to organizations is handled elsewhere, this just creates
# a blank team
return True
return False
@classmethod
def can_user_delete(cls, user, obj):
return cls.can_user_administrate(user, obj, None)
class Project(CommonModel):
'''
A project represents a playbook git repo that can access a set of inventories
'''
# this is not part of the project, but managed with perms
# inventories = models.ManyToManyField('Inventory', blank=True, related_name='projects')
local_path = models.FilePathField(
path=settings.PROJECTS_ROOT,
recursive=False,
allow_files=False,
allow_folders=True,
max_length=1024,
unique=True,
help_text=_('Local path (relative to PROJECTS_ROOT) containing '
'playbooks and related files for this project.')
)
#scm_type = models.CharField(max_length=64)
#default_playbook = models.CharField(max_length=1024)
def get_absolute_url(self):
return reverse('main:projects_detail', args=(self.pk,))
@classmethod
def can_user_administrate(cls, user, obj, data):
if user.is_superuser:
return True
if obj.created_by == user:
return True
organizations = Organization.objects.filter(admins__in = [ user ], projects__in = [ obj ])
for org in organizations:
if org in project.organizations():
return True
return False
@classmethod
def can_user_read(cls, user, obj):
if cls.can_user_administrate(user, obj, None):
return True
# and also if I happen to be on a team inside the project
# FIXME: add this too
return False
@classmethod
def can_user_delete(cls, user, obj):
return cls.can_user_administrate(user, obj, None)
@property
def available_playbooks(self):
playbooks = []
if self.local_path and os.path.exists(self.local_path):
for dirpath, dirnames, filenames in os.walk(self.local_path):
for filename in filenames:
if os.path.splitext(filename)[-1] != '.yml':
continue
playbook = os.path.join(dirpath, filename)
# Filter any invalid YAML files.
try:
data = yaml.safe_load(file(playbook).read())
except (IOError, yaml.YAMLError):
continue
# Filter files that do not have either hosts or top-level
# includes.
try:
if 'hosts' not in data[0] and 'include' not in data[0]:
continue
except (TypeError, IndexError, KeyError):
continue
playbook = os.path.relpath(playbook, self.local_path)
# Filter files in a roles subdirectory.
if 'roles' in playbook.split(os.sep):
continue
# Filter files in a tasks subdirectory.
if 'tasks' in playbook.split(os.sep):
continue
playbooks.append(playbook)
return playbooks
class Permission(CommonModelNameNotUnique):
'''
A permission allows a user, project, or team to be able to use an inventory source.
'''
class Meta:
app_label = 'main'
# permissions are granted to either a user or a team:
user = models.ForeignKey('auth.User', null=True, on_delete=SET_NULL, blank=True, related_name='permissions')
team = models.ForeignKey('Team', null=True, on_delete=SET_NULL, blank=True, related_name='permissions')
# to be used against a project or inventory (or a project and inventory in conjunction):
project = models.ForeignKey('Project', null=True, on_delete=SET_NULL, blank=True, related_name='permissions')
inventory = models.ForeignKey('Inventory', null=True, on_delete=SET_NULL, related_name='permissions')
# permission system explanation:
#
# for example, user A on inventory X has write permissions (PERM_INVENTORY_WRITE)
# team C on inventory X has read permissions (PERM_INVENTORY_READ)
# team C on inventory X and project Y has launch permissions (PERM_INVENTORY_DEPLOY)
# team C on inventory X and project Z has dry run permissions (PERM_INVENTORY_CHECK)
#
# basically for launching, permissions can be awarded to the whole inventory source or just the inventory source
# in context of a given project.
#
# the project parameter is not used when dealing with READ, WRITE, or ADMIN permissions.
permission_type = models.CharField(max_length=64, choices=PERMISSION_TYPE_CHOICES)
def __unicode__(self):
return unicode("Permission(name=%s,ON(user=%s,team=%s),FOR(project=%s,inventory=%s,type=%s))" % (
self.name,
self.user,
self.team,
self.project,
self.inventory,
self.permission_type
))
def get_absolute_url(self):
return reverse('main:permissions_detail', args=(self.pk,))
@classmethod
def can_user_administrate(cls, user, obj, data):
if user.is_superuser:
return True
# a permission can be administrated by a super
# or if a user permission, that an admin of a user's organization
# or if a team permission, an admin of that team's organization
if obj.user and obj.user.organizations.filter(admins__in = [user]).count() > 0:
return True
if obj.team and obj.team.organization.admins.filter(user=user).count() > 0:
return True
return False
@classmethod
def can_user_read(cls, user, obj):
# a permission can be seen by the assigned user or team
# or anyone who can administrate that permission
if obj.user and obj.user == user:
return True
if obj.team and obj.team.users.filter(pk = user.pk).count() > 0:
return True
return cls.can_user_administrate(user, obj, None)
@classmethod
def can_user_delete(cls, user, obj):
return cls.can_user_administrate(user, obj, None)
# TODO: other job types (later)
class JobTemplate(CommonModel):
'''
A job template is a reusable job definition for applying a project (with
playbook) to an inventory source with a given credential.
'''
class Meta:
app_label = 'main'
job_type = models.CharField(
max_length=64,
choices=JOB_TYPE_CHOICES,
)
inventory = models.ForeignKey(
'Inventory',
related_name='job_templates',
null=True,
on_delete=models.SET_NULL,
)
project = models.ForeignKey(
'Project',
related_name='job_templates',
null=True,
on_delete=models.SET_NULL,
)
playbook = models.CharField(
max_length=1024,
default='',
)
credential = models.ForeignKey(
'Credential',
related_name='job_templates',
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
)
use_sudo = models.NullBooleanField(
blank=True,
default=None,
)
forks = models.PositiveIntegerField(
blank=True,
default=0,
)
limit = models.CharField(
max_length=1024,
blank=True,
default='',
)
verbosity = models.PositiveIntegerField(
blank=True,
default=0,
)
extra_vars = JSONField(
blank=True,
default='',
)
def create_job(self, **kwargs):
'''
Create a new job based on this template.
'''
save_job = kwargs.pop('save', True)
kwargs['job_template'] = self
kwargs.setdefault('name', '%s %s' % (self.name, now().isoformat()))
kwargs.setdefault('description', self.description)
kwargs.setdefault('job_type', self.job_type)
kwargs.setdefault('inventory', self.inventory)
kwargs.setdefault('project', self.project)
kwargs.setdefault('playbook', self.playbook)
kwargs.setdefault('credential', self.credential)
kwargs.setdefault('use_sudo', self.use_sudo)
kwargs.setdefault('forks', self.forks)
kwargs.setdefault('limit', self.limit)
kwargs.setdefault('verbosity', self.verbosity)
kwargs.setdefault('extra_vars', self.extra_vars)
job = Job(**kwargs)
if save_job:
job.save()
return job
def get_absolute_url(self):
return reverse('main:job_templates_detail', args=(self.pk,))
@classmethod
def can_user_read(cls, user, obj):
# you can only see the job templates that you have permission to launch.
data = dict(
inventory = obj.inventory.pk,
project = obj.project.pk,
job_type = obj.job_type
)
return cls.can_user_add(user, data)
@classmethod
def can_user_administrate(cls, user, obj, data):
'''
'''
@classmethod
def can_user_add(cls, user, data):
'''
a user can create a job template if they are a superuser, an org admin of any org
that the project is a member, or if they have user or team based permissions tying
the project to the inventory source for the given action.
users who are able to create deploy jobs can also make check (dry run) jobs
'''
if user.is_superuser:
return True
if not data or '_method' in data: # FIXME: So the browseable API will work?
return True
project = Project.objects.get(pk=data['project'])
inventory = Inventory.objects.get(pk=data['inventory'])
admin_of_orgs = project.organizations.filter(admins__in = [ user ])
if admin_of_orgs.count() > 0:
return True
job_type = data['job_type']
has_launch_permission = False
user_permissions = Permission.objects.filter(inventory=inventory, project=project, user=user)
for perm in user_permissions:
if job_type == PERM_INVENTORY_CHECK:
# if you have run permissions, you can also create check jobs
has_launch_permission = True
elif job_type == PERM_INVENTORY_DEPLOY and perm.permission_type == PERM_INVENTORY_DEPLOY:
# you need explicit run permissions to make run jobs
has_launch_permission = True
team_permissions = Permission.objects.filter(inventory=inventory, project=project, team__users__in = [user])
for perm in team_permissions:
if job_type == PERM_INVENTORY_CHECK:
# if you have run permissions, you can also create check jobs
has_launch_permission = True
elif job_type == PERM_INVENTORY_DEPLOY and perm.permission_type == PERM_INVENTORY_DEPLOY:
# you need explicit run permissions to make run jobs
has_launch_permission = True
if not has_launch_permission:
return False
# make sure user owns the credentials they are using
if data.has_key('credential'):
has_credential = False
credential = Credential.objects.get(pk=data['credential'])
if credential.team and credential.team.users.filter(id = user.pk).count():
has_credential = True
if credential.user and credential.user == user:
has_credential = True
if not has_credential:
return False
# shouldn't really matter with permissions given, but make sure the user
# is also currently on the team in case they were added a per-user permission and then removed
# from the project.
if project.teams.filter(users__in = [ user ]).count():
return False
return True
class Job(CommonModel):
'''
A job applies a project (with playbook) to an inventory source with a given
credential. It represents a single invocation of ansible-playbook with the
given parameters.
'''
STATUS_CHOICES = [
('new', _('New')), # Job has been created, but not started.
('pending', _('Pending')), # Job has been queued, but is not yet running.
('running', _('Running')), # Job is currently running.
('successful', _('Successful')), # Job completed successfully.
('failed', _('Failed')), # Job completed, but with failures.
('error', _('Error')), # The job was unable to run.
('canceled', _('Canceled')), # The job was canceled before completion.
]
class Meta:
app_label = 'main'
job_template = models.ForeignKey(
'JobTemplate',
related_name='jobs',
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
)
job_type = models.CharField(
max_length=64,
choices=JOB_TYPE_CHOICES,
)
inventory = models.ForeignKey(
'Inventory',
related_name='jobs',
null=True,
on_delete=models.SET_NULL,
)
credential = models.ForeignKey(
'Credential',
related_name='jobs',
null=True,
on_delete=models.SET_NULL,
)
project = models.ForeignKey(
'Project',
related_name='jobs',
null=True,
on_delete=models.SET_NULL,
)
playbook = models.CharField(
max_length=1024,
)
use_sudo = models.NullBooleanField(
blank=True,
default=None,
)
forks = models.PositiveIntegerField(
blank=True,
default=0,
)
limit = models.CharField(
max_length=1024,
blank=True,
default='',
)
verbosity = models.PositiveIntegerField(
blank=True,
default=0,
)
extra_vars = JSONField(
blank=True,
default='',
)
cancel_flag = models.BooleanField(
blank=True,
default=False,
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='new',
editable=False,
)
result_stdout = models.TextField(
blank=True,
default='',
editable=False,
)
result_stderr = models.TextField(
blank=True,
default='',
editable=False,
)
result_traceback = models.TextField(
blank=True,
default='',
editable=False,
)
celery_task_id = models.CharField(
max_length=100,
blank=True,
default='',
editable=False,
)
hosts = models.ManyToManyField(
'Host',
related_name='jobs',
blank=True,
editable=False,
through='JobHostSummary',
)
def get_absolute_url(self):
return reverse('main:jobs_detail', args=(self.pk,))
@property
def celery_task(self):
try:
if self.celery_task_id:
return TaskMeta.objects.get(task_id=self.celery_task_id)
except TaskMeta.DoesNotExist:
pass
def get_passwords_needed_to_start(self):
'''Return list of password field names needed to start the job.'''
needed = []
for field in ('ssh_password', 'sudo_password', 'ssh_key_unlock'):
if self.credential and getattr(self.credential, 'needs_%s' % field):
needed.append(field)
return needed
def start(self, **kwargs):
from lib.main.tasks import RunJob
if self.status != 'new':
return False
needed = self.get_passwords_needed_to_start()
opts = dict([(field, kwargs.get(field, '')) for field in needed])
if not all(opts.values()):
return False
self.status = 'pending'
self.save(update_fields=['status'])
task_result = RunJob().delay(self.pk, **opts)
# The TaskMeta instance in the database isn't created until the worker
# starts processing the task, so we can only store the task ID here.
self.celery_task_id = task_result.task_id
self.save(update_fields=['celery_task_id'])
return True
def cancel(self):
if self.status in ('pending', 'running'):
if not self.cancel_flag:
self.cancel_flag = True
self.save(update_fields=['cancel_flag'])
return self.cancel_flag
@property
def successful_hosts(self):
return Host.objects.filter(job_host_summaries__job__pk=self.pk,
job_host_summaries__ok__gt=0)
@property
def failed_hosts(self):
return Host.objects.filter(job_host_summaries__job__pk=self.pk,
job_host_summaries__failures__gt=0)
@property
def changed_hosts(self):
return Host.objects.filter(job_host_summaries__job__pk=self.pk,
job_host_summaries__changed__gt=0)
@property
def dark_hosts(self):
return Host.objects.filter(job_host_summaries__job__pk=self.pk,
job_host_summaries__dark__gt=0)
@property
def unreachable_hosts(self):
return self.dark_hosts
@property
def skipped_hosts(self):
return Host.objects.filter(job_host_summaries__job__pk=self.pk,
job_host_summaries__skipped__gt=0)
@property
def processed_hosts(self):
return Host.objects.filter(job_host_summaries__job__pk=self.pk,
job_host_summaries__processed__gt=0)
class JobHostSummary(models.Model):
'''
Per-host statistics for each job.
'''
class Meta:
unique_together = [('job', 'host')]
verbose_name_plural = _('Job Host Summaries')
ordering = ('-pk',)
job = models.ForeignKey(
'Job',
related_name='job_host_summaries',
on_delete=models.CASCADE,
)
host = models.ForeignKey('Host',
related_name='job_host_summaries',
on_delete=models.CASCADE,
)
changed = models.PositiveIntegerField(default=0)
dark = models.PositiveIntegerField(default=0)
failures = models.PositiveIntegerField(default=0)
ok = models.PositiveIntegerField(default=0)
processed = models.PositiveIntegerField(default=0)
skipped = models.PositiveIntegerField(default=0)
def __unicode__(self):
return '%s changed=%d dark=%d failures=%d ok=%d processed=%d skipped=%s' % \
(self.host.name, self.changed, self.dark, self.failures, self.ok,
self.processed, self.skipped)
class JobEvent(models.Model):
'''
An event/message logged from the callback when running a job.
'''
EVENT_TYPES = [
('runner_on_failed', _('Runner on Failed')),
('runner_on_ok', _('Runner on OK')),
('runner_on_error', _('Runner on Error')),
('runner_on_skipped', _('Runner on Skipped')),
('runner_on_unreachable', _('Runner on Unreachable')),
('runner_on_no_hosts', _('Runner on No Hosts')),
('runner_on_async_poll', _('Runner on Async Poll')),
('runner_on_async_ok', _('Runner on Async OK')),
('runner_on_async_failed', _('Runner on Async Failed')),
('playbook_on_start', _('Playbook on Start')),
('playbook_on_notify', _('Playbook on Notify')),
('playbook_on_task_start', _('Playbook on Task Start')),
('playbook_on_vars_prompt', _('Playbook on Vars Prompt')),
('playbook_on_setup', _('Playbook on Setup')),
('playbook_on_import_for_host', _('Playbook on Import for Host')),
('playbook_on_not_import_for_host', _('Playbook on Not Import for Host')),
('playbook_on_play_start', _('Playbook on Play Start')),
('playbook_on_stats', _('Playbook on Stats')),
]
class Meta:
app_label = 'main'
job = models.ForeignKey(
'Job',
related_name='job_events',
on_delete=models.CASCADE,
)
created = models.DateTimeField(
auto_now_add=True,
)
event = models.CharField(
max_length=100,
choices=EVENT_TYPES,
)
event_data = JSONField(
blank=True,
default='',
)
host = models.ForeignKey(
'Host',
related_name='job_events',
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
)
def __unicode__(self):
return u'%s @ %s' % (self.get_event_display(), self.created.isoformat())
def save(self, *args, **kwargs):
try:
if not self.host and self.event_data.get('host', ''):
self.host = self.job.inventory.hosts.get(name=self.event_data['host'])
except (Host.DoesNotExist, AttributeError):
pass
super(JobEvent, self).save(*args, **kwargs)
self.update_host_summary_from_stats()
def update_host_summary_from_stats(self):
if self.event != 'playbook_on_stats':
return
hostnames = set()
try:
for v in self.event_data.values():
hostnames.update(v.keys())
except AttributeError: # In case event_data or v isn't a dict.
pass
for hostname in hostnames:
try:
host = self.job.inventory.hosts.get(name=hostname)
except Host.DoesNotExist:
continue
host_summary = self.job.job_host_summaries.get_or_create(host=host)[0]
host_summary_changed = False
for stat in ('changed', 'dark', 'failures', 'ok', 'processed', 'skipped'):
try:
value = self.event_data.get(stat, {}).get(hostname, 0)
if getattr(host_summary, stat) != value:
setattr(host_summary, stat, value)
host_summary_changed = True
except AttributeError: # in case event_data[stat] isn't a dict.
pass
if host_summary_changed:
host_summary.save()
# TODO: reporting (MPD)
@receiver(post_save, sender=User)
def create_auth_token_for_user(sender, **kwargs):
instance = kwargs.get('instance', None)
if instance:
try:
Token.objects.get_or_create(user=instance)
except DatabaseError:
pass # Only fails when creating a new superuser from syncdb on a
# new database (before migrate has been called).