Commit bcbed68b authored by Lukas Burgey's avatar Lukas Burgey
Browse files

Refactor the deployment hierarchy and adapt tests

parent 17501f71
......@@ -33,6 +33,5 @@ admin.site.register(models.Site)
admin.site.register(models.Service)
admin.site.register(models.SSHPublicKey)
admin.site.register(models.Deployment)
admin.site.register(models.DeploymentState)
admin.site.register(models.DeploymentStateItem)
admin.site.register(models.NewDeployment)
admin.site.register(models.NewDeploymentStateItem)
......@@ -12,14 +12,22 @@ from .users import User, SSHPublicKey
LOGGER = getLogger(__name__)
DEPLOYMENT_PENDING = 'deployment_pending'
REMOVAL_PENDING = 'removal_pending'
NOT_DEPLOYED = 'not_deployed'
DEPLOYED = 'deployed'
QUESTIONNAIRE = 'questionnaire'
FAILED = 'failed'
REJECTED = 'rejected'
STATE_CHOICES = (
('deployment_pending', 'Deployment Pending'),
('removal_pending', 'Removal Pending'),
('deployed', 'Deployed'),
('not_deployed', 'Not Deployed'),
('questionnaire', 'Questionnaire'),
('failed', 'Failed'),
('rejected', 'Rejected'),
(DEPLOYMENT_PENDING, 'Deployment Pending'),
(REMOVAL_PENDING, 'Removal Pending'),
(DEPLOYED, 'Deployed'),
(NOT_DEPLOYED, 'Not Deployed'),
(QUESTIONNAIRE, 'Questionnaire'),
(FAILED, 'Failed'),
(REJECTED, 'Rejected'),
)
def questionnaire_default():
return {}
......@@ -82,6 +90,26 @@ class Service(models.Model):
blank=True,
)
@classmethod
def get_service(cls, name, description='', sites=None, groups=None):
try:
return cls.objects.get(name=name)
except cls.DoesNotExist:
service = cls(
name=name,
description=description,
)
service.save()
if sites is not None:
for site in sites:
service.site.add(site)
if groups is not None:
for group in groups:
service.groups.add(group)
return service
def __str__(self):
return self.name
......@@ -98,27 +126,12 @@ class Service(models.Model):
try:
deployment = user.deployments.get(group=group)
deployment.service_added(self)
except Deployment.DoesNotExist:
except NewDeployment.DoesNotExist:
LOGGER.error('Inconsistency of group deployment')
raise
# Deployment describes the supposed state of the users ssh keys at either:
# - a group (and and the services associated with the group)
# - a single service
#
# DeploymentState track the state of a single ssh key at either:
# - a group (and and the services associated with the group)
# - a single service
# DeploymentStateItem track the acknowledgements from the clients for either :
# - the sites that handle the associated group (i.e provide a service for members of the group)
# - the sites that provide the associated service
#
# Note: two possible kinds of Deployment:
# (group is None and service is not None) or
# (group is not None and service is None)
class Deployment(models.Model):
class NewDeployment(models.Model):
user = models.ForeignKey(
User,
related_name='deployments',
......@@ -139,279 +152,144 @@ class Deployment(models.Model):
null=True,
blank=True,
)
ssh_keys = models.ManyToManyField(
SSHPublicKey,
related_name='deployments',
blank=True,
# which state do we currently want to reach?
state_target = models.CharField(
max_length=50,
choices=STATE_CHOICES,
default=NOT_DEPLOYED,
)
is_active = models.BooleanField(
default=True,
)
# only used when group is not None and service is None
# credentials provided by the backend to the clients
@property
def user_credentials(self):
return self.user.credentials
@property
def state(self):
if self.state_items.exists():
_state = ''
for state in self.state_items.all():
LOGGER.debug('FOO: %s', state)
if _state == '':
_state = state.state
elif _state != state.state:
return 'mixed'
return _state
# if we have no states we have nothing to do
return self.state_target
@property
def target_reached(self):
return self.state_target == self.state
@property
def services(self):
if self.group is not None:
return self.group.services.all()
return None
return [self.service]
# only used when group is not None and service is None
@property
def sites(self):
return Site.objects.filter(services__groups=self.group).distinct()
return [
service.site
for service in self.services
]
def create_state_items(self, site=None):
for service in self.services:
LOGGER.debug('create_state_items: creating NewDeploymentStateItem for service %s at sites %s', service, service.site.all())
if site is None:
if not service.site.exists():
raise ValueError('Cannot create state item for service without site')
for service_site in service.site.all():
# LOGGER.debug('create_state_items: creating NewDeploymentStateItems for service %s at site %s', service, service_site)
NewDeploymentStateItem.get_state_item(
parent=self,
site=service_site,
service=service,
).save()
else:
NewDeploymentStateItem.get_state_item(
parent=self,
site=site,
service=service,
).save()
# get a deployment for a user/service.
# if it does not exist it is created
@classmethod
def get_deployment(cls, user, service=None, group=None):
def get_deployment(cls, user, service=None, group=None, site=None):
if service is None and group is None:
raise ValueError('get_deployment needs a service or a group')
try:
dep = None
if service is not None:
return cls.objects.get(
dep = cls.objects.get(
user=user,
service=service,
)
LOGGER.debug('service hit')
elif group is not None:
return cls.objects.get(
dep = cls.objects.get(
user=user,
group=group,
)
else:
raise ValueError('Unable to create Deployment without service and group')
LOGGER.debug('group hit')
return dep
except cls.DoesNotExist:
deployment = None
if service is not None:
if service is not None and group is None:
deployment = cls(
user=user,
service=service,
)
elif group is not None:
deployment = cls(
user=user,
group=group,
)
if not group.services.exists():
LOGGER.info(deployment.msg('No services for group'))
deployment.save()
deployment.create_state_items(site=site)
LOGGER.debug(deployment.msg('created'))
return deployment
# deploy credentials which were deployed prior to deactivation
def activate(self):
if self.is_active:
LOGGER.error(self.msg('already active'))
return
for key in self.ssh_keys.all():
self._deploy_key(key)
self.is_active = True
self.save()
LOGGER.info(self.msg('activated'))
# remove all credentials
def deactivate(self):
if not self.is_active:
LOGGER.error(self.msg('already deactivated'))
return
self.is_active = False
self.save()
for key in self.ssh_keys.all():
self._remove_key(key)
LOGGER.info(self.msg('deactivated'))
# deploy key and track changes in the key lists
def deploy_key(self, key):
if not self.is_active:
LOGGER.error(self.msg('cannot deploy while deactivated'))
raise Exception('deployment deactivated')
self.ssh_keys.add(key)
self.save()
self._deploy_key(key)
def service_added(self, service):
# a new service for this group was added and we may have to deploy some keys
LOGGER.debug(self.msg('Adding service {}'.format(service)))
for state in self.states.all():
state.service_added(service)
# remove key and track changes in the key lists
def remove_key(self, key):
if not self.is_active:
LOGGER.error(self.msg('cannot remove while deactivated'))
raise Exception('deployment deactivated')
self.ssh_keys.remove(key)
self.save()
self._remove_key(key)
# only deploy the key
def _deploy_key(self, key):
state = DeploymentState.get_state(
deployment=self,
key=key,
)
state.save()
state.deploy()
def _remove_key(self, key):
state = DeploymentState.get_state(
deployment=self,
key=key,
)
state.save()
state.remove()
def __str__(self):
if self.service is not None:
return '{}:{}'.format(self.service, self.user)
return '{}:{}'.format(self.group, self.user)
def msg(self, msg):
return '[Depl.m:{}] {}'.format(self, msg)
# DeploymentState: knows:
# user, service, key, state_target
class DeploymentState(models.Model):
key = models.ForeignKey(
SSHPublicKey,
related_name='states',
# deleting the key leaves us without references about its deployments
# we _HAVE_ to remove all deployments prior to deleting key
on_delete=models.CASCADE,
)
deployment = models.ForeignKey(
Deployment,
related_name='states',
on_delete=models.CASCADE,
)
# which state do we currently want to reach?
state_target = models.CharField(
max_length=50,
choices=STATE_CHOICES,
default='deployed',
)
# credentials provided by the backend to the clients
@property
def credentials(self):
# FIXME hacky
ssh_keys = [{
'name': key.name,
'value': key.key
}
for key in self.user.ssh_keys.all()]
return {
'ssh_key': ssh_keys
}
@property
def user(self):
return self.deployment.user
@property
def states(self):
return [item.state for item in self.state_items.all()]
@property
def state(self):
if self.states:
_state = ''
for state in self.states:
if _state == '':
_state = state
elif _state != state:
return 'mixed'
return _state
# if we have no states we have nothing to do
return self.state_target
@property
def target_reached(self):
return self.state_target == self.state
@property
def service(self):
return self.deployment.service
@property
def services(self):
return self.deployment.services
@property
def group(self):
return self.deployment.group
# get a state for a user/service.
# if it does not exist it is created
@classmethod
def get_state(cls, deployment, key):
# check if a state does already exist
state = None
try:
state = cls.objects.get(
deployment=deployment,
key=key,
)
return state
except cls.DoesNotExist:
state = cls(
deployment=deployment,
key=key,
)
state.save()
LOGGER.debug(state.msg('created'))
except cls.MultipleObjectsReturned:
LOGGER.error(
deployment.msg('to many DeploymentState objects for key {}'.format(key.name))
)
raise
# generate state items
if deployment.service is not None:
for site in deployment.service.site.all():
DeploymentStateItem.get_state_item(
parent=state,
site=site,
service=deployment.service,
).save()
elif deployment.group is not None:
# every site which provides a service for group
for site in deployment.sites:
for service in Service.objects.filter(
groups=deployment.group,
site=site,
):
DeploymentStateItem.get_state_item(
parent=state,
site=site,
service=service,
).save()
def user_deploy(self):
self._set_target('deployed')
for item in self.state_items.all():
item.user_deploy()
self.publish_to_client()
# each state item publishes its state to the user
return state
def user_remove(self):
self._set_target('not_deployed')
for item in self.state_items.all():
item.user_remove()
self.publish_to_client()
# each state item publishes its state to the user
def service_added(self, service):
LOGGER.debug(self.msg('Adding service {}'.format(service)))
for site in service.site.all():
# create new DeploymentStateItems
item = DeploymentStateItem.get_state_item(
# create new NewDeploymentStateItems
item = NewDeploymentStateItem.get_state_item(
parent=self,
site=site,
service=service,
......@@ -420,25 +298,11 @@ class DeploymentState(models.Model):
if self.state_target == 'deployed':
item.user_deploy()
def deploy(self):
self._set_target('deployed')
for item in self.state_items.all():
item.user_deploy()
self.publish_to_client()
# each state item publishes its state to the user
def remove(self):
self._set_target('not_deployed')
for item in self.state_items.all():
item.user_remove()
self.publish_to_client()
# each state item publishes its state to the user
def publish_to_client(self):
# mitigating circular dependencies here
from .serializers.clients import DeploymentStateSerializer
data = DeploymentStateSerializer(self).data
data['credentials'] = self.credentials
from .serializers.clients import NewDeploymentSerializer
data = NewDeploymentSerializer(self).data
data['credentials'] = self.user_credentials
msg = dumps(data)
if self.service is not None:
......@@ -452,7 +316,7 @@ class DeploymentState(models.Model):
msg,
)
else:
LOGGER.error('Deployment as neither a group or a service')
LOGGER.error('Deployment has neither a group or a service')
# update the state of the remote webpage
def publish_to_user(self):
......@@ -467,7 +331,7 @@ class DeploymentState(models.Model):
)
def msg(self, msg):
return '[DState:{}] {}'.format(self, msg)
return '[Deploy:{}] {}'.format(self, msg)
def _set_target(self, target):
self.state_target = target
......@@ -478,21 +342,19 @@ class DeploymentState(models.Model):
if self.service is not None:
return '{}:{}#{}'.format(
self.service,
self.key.name,
self.user,
self.id,
)
return '{}:{}#{}'.format(
self.group,
self.key.name,
self.user,
self.id,
)
# DeploymentStateItem: knows:
# user, service, key, state_target, _and_ site
class DeploymentStateItem(models.Model):
class NewDeploymentStateItem(models.Model):
parent = models.ForeignKey(
DeploymentState,
NewDeployment,
related_name='state_items',
on_delete=models.CASCADE,
)
......@@ -509,7 +371,7 @@ class DeploymentStateItem(models.Model):
state = models.CharField(
max_length=50,
choices=STATE_CHOICES,
default='deployment_pending',
default=NOT_DEPLOYED,
)
# message for the user
......@@ -537,8 +399,8 @@ class DeploymentStateItem(models.Model):
return self.parent.user
@property
def key(self):
return self.parent.key
def user_credentials(self):
return self.parent.credentials
@property
def group(self):
......@@ -547,10 +409,14 @@ class DeploymentStateItem(models.Model):
@classmethod
def get_state_item(cls, parent=None, site=None, service=None):
try:
return parent.state_items.get(
item = cls.objects.get(
parent=parent,
site=site,
service=service,
)
LOGGER.debug('get_state_item hit')
return item
except cls.DoesNotExist:
item = cls(
parent=parent,
......@@ -656,16 +522,14 @@ class DeploymentStateItem(models.Model):
def __str__(self):
if self.group is not None:
return '{}:{}@{}:{}#{}'.format(
return '{}:{}@{}#{}'.format(
self.group,
self.service,
self.site,
self.key.name,
self.id,
)
return '{}:@{}:{}#{}'.format(
return '{}:@{}#{}'.format(
self.service,
self.site,
self.key.name,
self.id,
)
......@@ -37,5 +37,5 @@ class SSHPublicKeyRefSerializer(serializers.ModelSerializer):
# "exports"
from .webpage import DeploymentStateSerializer
from .webpage import NewDeploymentSerializer
from .clients import RabbitMQInstanceSerializer
......@@ -5,7 +5,7 @@ from django_mysql.models import JSONField
from rest_framework import serializers
from ... import models
from . import GroupSerializer, SSHPublicKeySerializer
from . import GroupSerializer
class ServiceSerializer(serializers.ModelSerializer):
......@@ -25,51 +25,33 @@ class UserSerializer(serializers.ModelSerializer):
fields = ['email', 'groups', 'userinfo']
class DeploymentSerializer(serializers.ModelSerializer):
class NewDeploymentSerializer(serializers.ModelSerializer):
user = UserSerializer()
service = ServiceSerializer()