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

Add polymorphic deployments

parent 549b7bef
......@@ -5,6 +5,7 @@ from django.contrib.auth.admin import UserAdmin
from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin
from . import models
from .models import deployments
from .auth.v1.models import OIDCConfig
from .auth.v1.models.vo import VO, Group, Entitlement, EntitlementNameSpace
......@@ -54,8 +55,26 @@ admin.site.register(models.User, ClientAdmin)
admin.site.register(models.Site)
admin.site.register(models.Service)
admin.site.register(models.SSHPublicKey)
admin.site.register(models.CredentialState)
admin.site.register(models.Deployment)
admin.site.register(models.DeploymentState)
@admin.register(deployments.VODeployment)
class VODeploymentAdmin(PolymorphicChildModelAdmin):
show_in_index = True
@admin.register(deployments.ServiceDeployment)
class ServiceDeploymentAdmin(PolymorphicChildModelAdmin):
show_in_index = True
@admin.register(deployments.Deployment)
class DeploymentAdmin(PolymorphicChildModelAdmin):
base_model = Entitlement # Explicitly set here!
show_in_index = True # makes child model admin visible in main admin site
child_models = (deployments.VODeployment, deployments.ServiceDeployment)
admin.site.register([
deployments.DeploymentState,
deployments.CredentialState,
])
......@@ -40,7 +40,6 @@ class EntitlementSerializer(serializers.ModelSerializer):
fields = VO_FIELDS + (
'name_space',
'group_authority',
'role',
)
......
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('backend', '0025_auto_20181121_1149'),
]
operations = [
migrations.DeleteModel('Deployment'),
migrations.DeleteModel('DeploymentState'),
migrations.DeleteModel('CredentialState'),
]
# Generated by Django 2.1.3 on 2018-11-26 12:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_mysql.models
import feudal.backend.models.deployments
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('backend', '0026_delete_deployment'),
]
operations = [
migrations.CreateModel(
name='CredentialState',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state_target', models.CharField(choices=[('deployed', 'Deployed'), ('not_deployed', 'Not Deployed')], default='not_deployed', max_length=50)),
('state', models.CharField(choices=[('deployment_pending', 'VODeployment Pending'), ('removal_pending', 'Removal Pending'), ('deployed', 'Deployed'), ('not_deployed', 'Not Deployed'), ('questionnaire', 'Questionnaire'), ('failed', 'Failed'), ('rejected', 'Rejected')], default='not_deployed', max_length=50)),
('_credential_deleted', models.BooleanField(default=False)),
('credential', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credential_states', to='backend.SSHPublicKey')),
],
),
migrations.CreateModel(
name='Deployment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state_target', models.CharField(choices=[('deployed', 'Deployed'), ('not_deployed', 'Not Deployed')], default='not_deployed', max_length=50)),
('is_active', models.BooleanField(default=True)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='DeploymentState',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('deployment_pending', 'VODeployment Pending'), ('removal_pending', 'Removal Pending'), ('deployed', 'Deployed'), ('not_deployed', 'Not Deployed'), ('questionnaire', 'Questionnaire'), ('failed', 'Failed'), ('rejected', 'Rejected')], default='not_deployed', max_length=50)),
('message', models.TextField(default='', max_length=300)),
('questionnaire', django_mysql.models.JSONField(blank=True, default=feudal.backend.models.deployments.questionnaire_default, null=True)),
('credentials', django_mysql.models.JSONField(blank=True, default=feudal.backend.models.deployments.credential_default, null=True)),
],
),
migrations.CreateModel(
name='ServiceDeployment',
fields=[
('deployment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='backend.Deployment')),
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_deployments', to='backend.Service')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('backend.deployment',),
),
migrations.CreateModel(
name='VODeployment',
fields=[
('deployment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='backend.Deployment')),
('vo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vo_deployments', to='backend.VO')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('backend.deployment',),
),
migrations.AddField(
model_name='deploymentstate',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_items', to='backend.Deployment'),
),
migrations.AddField(
model_name='deploymentstate',
name='service',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_items', to='backend.Service'),
),
migrations.AddField(
model_name='deploymentstate',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_items', to='backend.Site'),
),
migrations.AddField(
model_name='deploymentstate',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_items', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='deployment',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_backend.deployment_set+', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='deployment',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployments', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='credentialstate',
name='target',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credential_states', to='backend.DeploymentState'),
),
]
from json import dumps
from logging import getLogger
from django.db import models
from django_mysql.models import JSONField
from django.conf import settings
# these imports are exports!
from ..auth.v1.models.vo import VO
from .brokers import RabbitMQInstance
from .users import User, SSHPublicKey
from .users import User
LOGGER = getLogger(__name__)
DEPLOYMENT_PENDING = 'deployment_pending'
REMOVAL_PENDING = 'removal_pending'
NOT_DEPLOYED = 'not_deployed'
DEPLOYED = 'deployed'
QUESTIONNAIRE = 'questionnaire'
FAILED = 'failed'
REJECTED = 'rejected'
TARGET_CHOICES = (
(DEPLOYED, 'Deployed'),
(NOT_DEPLOYED, 'Not Deployed'),
)
STATE_CHOICES = (
(DEPLOYMENT_PENDING, 'Deployment Pending'),
(REMOVAL_PENDING, 'Removal Pending'),
(DEPLOYED, 'Deployed'),
(NOT_DEPLOYED, 'Not Deployed'),
(QUESTIONNAIRE, 'Questionnaire'),
(FAILED, 'Failed'),
(REJECTED, 'Rejected'),
)
def questionnaire_default():
return {}
......@@ -140,7 +114,8 @@ class Service(models.Model):
return self.name
def remove_service(self):
for deployment in Deployment.objects.filter(
from . import deployments
for deployment in deployments.Deployment.objects.filter(
vo__services=self,
):
deployment.service_removed(self)
......@@ -158,593 +133,6 @@ class Service(models.Model):
try:
deployment = user.deployments.get(vo=vo)
deployment.service_added(self)
except Deployment.DoesNotExist:
except deployments.Deployment.DoesNotExist:
LOGGER.error('Inconsistency of vo deployment')
raise
class Deployment(models.Model):
user = models.ForeignKey(
User,
related_name='deployments',
on_delete=models.CASCADE,
)
vo = models.ForeignKey(
VO,
related_name='deployments',
on_delete=models.CASCADE,
)
# which state do we currently want to reach?
state_target = models.CharField(
max_length=50,
choices=TARGET_CHOICES,
default=NOT_DEPLOYED,
)
is_active = models.BooleanField(
default=True,
)
# credentials provided by the backend to the clients
@property
def credentials(self):
return self.user.credentials
@property
def state(self):
if self.state_items.exists():
_state = ''
for state in self.state_items.all():
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):
return self.vo.services.all()
def create_state_items(self):
for service in self.services:
LOGGER.debug('create_state_items: creating DeploymentState for service %s at site %s', service, service.site)
DeploymentState.get_state_item(
parent=self,
user=self.user,
site=service.site,
service=service,
)
@classmethod
def get_deployment(cls, user, vo):
try:
deployment = cls.objects.get(
user=user,
vo=vo,
)
deployment.create_state_items()
return deployment
except cls.DoesNotExist:
deployment = cls(
user=user,
vo=vo,
)
deployment.save()
deployment.create_state_items()
LOGGER.debug(deployment.msg('created'))
return deployment
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
def user_remove(self):
for item in self.state_items.all():
if item.state != NOT_DEPLOYED:
item.user_remove()
self._set_target(NOT_DEPLOYED)
self.publish_to_client()
# each state item publishes its state to the user
def user_credential_added(self, key):
for item in self.state_items.all():
item.user_credential_added(key)
self.publish()
def user_credential_removed(self, key):
for item in self.state_items.all():
item.user_credential_removed(key)
self.publish()
def service_added(self, service):
LOGGER.debug(self.msg('Adding service {}'.format(service)))
item = DeploymentState.get_state_item(
parent=self,
site=service.site,
service=service,
)
if self.state_target == 'deployed':
item.user_deploy()
def service_removed(self, service):
LOGGER.debug(self.msg('Removing service {}'.format(service)))
LOGGER.debug('TODO implement service removal')
def publish_to_client(self):
# avoiding circular dependencies here
from .serializers.clients import DeploymentSerializer
data = DeploymentSerializer(self).data
msg = dumps(data)
RabbitMQInstance.load().publish_by_vo(
self.vo,
msg,
)
# sends a state update via RabbitMQ / STOMP to the users webpage instance
def publish_to_user(self):
if self.user is None:
return
# avoiding circular dependencies here
from .serializers.webpage import DeploymentSerializer
msg = dumps({
'deployment': DeploymentSerializer(self).data,
})
RabbitMQInstance.load().publish_to_user(
self.user,
msg,
)
def publish(self):
self.publish_to_user()
self.publish_to_client()
def msg(self, msg):
return '{} - {}'.format(self, msg)
def _set_target(self, target):
self.state_target = target
for credential_state in CredentialState.objects.filter(
target__parent=self,
):
credential_state.set_target(target)
LOGGER.debug(self.msg('Target changed to '+target))
self.save()
def __str__(self):
return 'VO-Dep: ({}:{})#{}'.format(
self.vo,
self.user,
self.id,
)
class DeploymentState(models.Model):
parentless = ValueError('Tried to access parent of parentless deployment state')
parent = models.ForeignKey(
Deployment,
related_name='state_items',
on_delete=models.SET_NULL,
null=True
)
user = models.ForeignKey(
User,
related_name='state_items',
on_delete=models.SET_NULL,
blank=True,
null=True,
)
site = models.ForeignKey(
Site,
related_name='state_items',
on_delete=models.CASCADE,
)
service = models.ForeignKey(
Service,
related_name='state_items',
on_delete=models.CASCADE,
)
state = models.CharField(
max_length=50,
choices=STATE_CHOICES,
default=NOT_DEPLOYED,
)
# message for the user
message = models.TextField(
max_length=300,
default='',
)
# questions for the user (needed for deployment
questionnaire = JSONField(
default=questionnaire_default,
null=True,
blank=True,
)
# credentials for the service
# only valid when state == deployed
credentials = JSONField(
default=credential_default,
null=True,
blank=True,
)
@property
def is_pending(self):
# TODO
# pending because we are orphaned -> pending until removed everywhere
if self.parent is None:
return True
# pending because the state target is not reached
if self.parent.state_target != self.state:
return True
return False
@property
def is_credential_pending(self):
for credential_state in self.credential_states.all():
if credential_state.is_pending:
return True
return False
@property
def user_credentials(self):
return self.user.credentials
@property
def vo(self):
if self.parent is not None:
return self.parent.vo
else:
raise self.parentless
@classmethod
def get_state_item(cls, parent=None, user=None, site=None, service=None):
try:
item = cls.objects.get(
parent=parent,
user=user,
site=site,
service=service,
)
return item
except cls.DoesNotExist:
item = cls(
parent=parent,
user=user,
site=site,
service=service,
)
item.save()
LOGGER.debug('get_state_item: created %s', item)
return item
# starts tracking this the credential for this item
def user_credential_added(self, credential):
if settings.DEBUG_CREDENTIALS:
LOGGER.debug('user_credential_added: %s %s', self, credential)
CredentialState.get_credential_state(
credential,
self,
)
def user_credential_removed(self, credential):
if settings.DEBUG_CREDENTIALS:
LOGGER.debug('user_credential_removed: %s %s', self, credential)
try:
credential_state = self.credential_states.get(credential=credential)
credential_state.credential_deleted()
except CredentialState.DoesNotExist:
LOGGER.error(self.msg('Credential {} has no CredentialState'.format(credential)))
# STATE TRANSITIONS
# user: provisioning requested
def user_deploy(self):
if self.state == REMOVAL_PENDING:
self._set_state(DEPLOYED)
return
if self.state == DEPLOYED:
LOGGER.info(self.msg('ignoring invalid state transition user_deploy'))
return
self._set_state(
DEPLOYMENT_PENDING,
publish=False, # the post response already contains the update
)
# user: deprovisioning requested
def user_remove(self):
if self.parent is None:
LOGGER.error('user_remove: parentless')
return
if (
self.parent.state_target == DEPLOYED
and (self.state == FAILED or self.state == REJECTED)
):
self._reset()
self._set_state(NOT_DEPLOYED, publish=False)
return
if self.state == NOT_DEPLOYED:
LOGGER.info(self.msg('ignoring invalid state transition user_remove'))
return
# FIXME this will break if the client 'finishes' the deployment, after user_remove
if (
self.state == DEPLOYMENT_PENDING
or self.state == QUESTIONNAIRE
):
self._set_state(NOT_DEPLOYED)
return
self._set_state(
REMOVAL_PENDING,
publish=False, # the post response already contains the update
)
# user: questionnaire answered
def user_answers(self, answers=None):
if self.parent is None:
LOGGER.error('user_answers: parentless')
return