Commit 2d6aa652 authored by lukas.burgey's avatar lukas.burgey

Merge branch 'dev'

parents b0f2d9b3 96759d92
Pipeline #115109 passed with stage
in 1 minute and 24 seconds
......@@ -10,7 +10,7 @@ from rest_framework.test import APIClient
from feudal.backend.brokers import RabbitMQInstance
from feudal.backend.models import Site, Service
from feudal.backend.models.deployments import get_deployment, DEPLOYED
from feudal.backend.models.deployments import DEPLOYED, VODeployment, ServiceDeployment
from feudal.backend.models.users import User
from feudal.backend.models.auth import OIDCConfig
from feudal.backend.models.auth.vos import VO, Group, Entitlement
......@@ -103,6 +103,36 @@ def userinfo(idp, entitlement, group):
idp.userinfo_field_entitlements: [entitlement.name],
}
@pytest.fixture
def userinfo_without_lists(idp, entitlement, group):
return {
'iss': idp.issuer_uri,
'sub':'fb0fa558-cfa2-49f9-b847-5c651d1f6135',
'ssh_key': 'ssh-rsa AAAAB3NzaC1yhApzBpUulukg9Q== TEST_KEY',
'name': 'Gustav Holst',
'given_name': 'Gustav',
'family_name': 'Holst',
'preferred_username': 'gustav.holst',
'email': 'gustav@holst-feudal-test.co.uk',
idp.userinfo_field_groups: group.name,
idp.userinfo_field_entitlements: entitlement.name,
}
@pytest.fixture
def userinfo_invalid_vos(idp):
return {
'iss': idp.issuer_uri,
'sub':'fb0fa558-cfa2-49f9-b847-5c651d1f6135',
'ssh_key': 'ssh-rsa AAAAB3NzaC1yhApzBpUulukg9Q== TEST_KEY',
'name': 'Gustav Holst',
'given_name': 'Gustav',
'family_name': 'Holst',
'preferred_username': 'gustav.holst',
'email': 'gustav@holst-feudal-test.co.uk',
idp.userinfo_field_groups: 0, # invalid value
idp.userinfo_field_entitlements: 0, # invalid value
}
@pytest.fixture
def password():
return 'foobarbaz'
......@@ -157,7 +187,7 @@ def service(site, group, entitlement, vo):
# a deployment with a state
@pytest.fixture
def dep(user, service):
return get_deployment(user, service=service)
return ServiceDeployment.get_or_create(user, service)
@pytest.fixture
def dep_state(dep):
......@@ -231,9 +261,9 @@ def _typed_deployed_deployment(deployment_type, user, service, vo, downstream_te
# get a deployment for the user
dep = None
if deployment_type == 'vo':
dep = get_deployment(user, vo=vo)
dep = VODeployment.get_or_create(user, vo)
else:
dep = get_deployment(user, service=service)
dep = ServiceDeployment.get_or_create(user, service)
assert dep is not None
......@@ -290,10 +320,10 @@ def _typed_pending_deployment(deployment_type, user, service, vo):
# get a deployment for the user
dep = None
if deployment_type == 'vo':
dep = get_deployment(user, vo=vo)
dep = VODeployment.get_or_create(user, vo)
assert dep.vo == vo
else:
dep = get_deployment(user, service=service)
dep = ServiceDeployment.get_or_create(user, service)
assert dep is not None
......
# Generated by Django 3.0.8 on 2020-09-24 09:38
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('backend', '0020_auto_20200922_1333'),
]
operations = [
migrations.CreateModel(
name='UserPreferences',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('deployment_mode', models.CharField(choices=[('both', 'Pick a combination of services and VOs for deployment.'), ('services-only', 'Pick individual services for deployment'), ('vos-only', 'Pick individual VOs for deployment')], default='both', max_length=20)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='preferences', to=settings.AUTH_USER_MODEL)),
],
),
]
......@@ -60,11 +60,6 @@ class VO(PolymorphicModel):
def pretty_name(self):
return self.name
@property
def broker_exchange(self): # pragma: no cover
# overridden in the implementations
return None
def __str__(self):
return self.name
......@@ -100,10 +95,6 @@ class Group(VO):
def pretty_name(self):
return self.name
@property
def broker_exchange(self): # pragma: no cover
return 'groups'
def __str__(self):
return 'VO-GROUP-'+self.name
......@@ -158,10 +149,6 @@ class Entitlement(VO):
def full_name(self):
return self.__repr__()
@property
def broker_exchange(self): # pragma: no cover
return 'entitlements'
@staticmethod
def extract_name(raw_name):
name_search = re.search('^(.*)#', raw_name)
......
This diff is collapsed.
......@@ -3,7 +3,7 @@ from rest_framework.serializers import ModelSerializer, Serializer, CharField, J
from rest_polymorphic.serializers import PolymorphicSerializer
from feudal.backend.models import Site, Service
from feudal.backend.models.users import User, SSHPublicKey
from feudal.backend.models.users import User, SSHPublicKey, UserPreferences
from feudal.backend.models.deployments import CredentialState, DeploymentState, Deployment, VODeployment, ServiceDeployment
from feudal.backend.models.auth.serializers import VOSerializer
......@@ -158,12 +158,22 @@ class DeploymentSerializer(PolymorphicSerializer):
}
class UserPreferencesSerializer(ModelSerializer):
class Meta:
model = UserPreferences
fields = (
'deployment_mode',
)
class UserStateSerializer(ModelSerializer):
deployments = DeploymentSerializer(many=True)
services = ServiceSerializer(many=True)
ssh_keys = SSHPublicKeySerializer(many=True)
states = DeploymentStateSerializer(many=True)
vos = VOSerializer(many=True)
preferences = UserPreferencesSerializer()
class Meta:
model = User
......@@ -176,6 +186,7 @@ class UserStateSerializer(ModelSerializer):
'states',
'userinfo',
'vos',
'preferences',
)
......
......@@ -299,6 +299,7 @@ def test_upstream_changed_userinfo_redeploy(user, deployed_vo_deployment, upstre
assert state.userinfo['name'] == 'new name'
# }}}
# SERIALIZERS {{{
def test_deployment_state_serializer(pending_vo_deployment):
# setup a deployed deployment for the service
......@@ -307,3 +308,76 @@ def test_deployment_state_serializer(pending_vo_deployment):
ser = webpage_serializers.DeploymentStateSerializer(state)
assert ser.data is not None
# }}}
# VO-Deployment and Service-Deployment interaction {{{
def test_both_service_and_vo_deployments_remove(pending_vo_deployment, pending_service_deployment, service):
"""
The user first requests a VO-Dep which deploys a Service.
Then an additional Service-Deployment for the same Service (which actually should change nothing)
Then the VO-Deployment is removed.
The Service must still be deployed because of the Service-Deployment
"""
vo_dep, state_from_vo = pending_vo_deployment(service=service)
assert state_from_vo.is_pending
service_dep, state_from_service = pending_service_deployment(service=service)
assert state_from_service.is_pending
# these two deployments should've mapped to the same state
assert state_from_vo.pk == state_from_service.pk
state = state_from_vo # might as well just call it state from here on
# remove first dep
vo_dep.state_target = NOT_DEPLOYED
vo_dep.save()
vo_dep.target_changed()
# still pending as there is still the service deployment
assert state.is_pending
# remove second dep
service_dep.state_target = NOT_DEPLOYED
service_dep.save()
service_dep.target_changed()
state.refresh_from_db()
# both deployments are set NOT_DEPLOYED so the state is now pending
assert not state.is_orphaned
assert state.state == NOT_DEPLOYED
assert not state.is_pending
def test_both_service_and_vo_deployments_delete(pending_vo_deployment, pending_service_deployment, service):
"""
The same as test_both_service_and_vo_deployments_remove but we delete the deployments instead of removing them
"""
vo_dep, state_from_vo = pending_vo_deployment(service=service)
assert state_from_vo.is_pending
service_dep, state_from_service = pending_service_deployment(service=service)
assert state_from_service.is_pending
# these two deployments should've mapped to the same state
assert state_from_vo.pk == state_from_service.pk
state = state_from_vo # might as well just call it state from here on
# delete first dep
vo_dep.delete()
# still pending as there is still the service deployment
assert state.is_pending
# delete second dep
service_dep.delete()
state.refresh_from_db()
# both deployments are deleted so the state is now pending
assert state.is_orphaned
assert state.state == NOT_DEPLOYED
assert state.is_pending # because the state is orphaned
# }}}
......@@ -5,7 +5,7 @@ import logging
from feudal.backend.tests import BaseTestCase
from feudal.backend.models import Service
from feudal.backend.models.deployments import (
Deployment, get_deployment,
Deployment, VODeployment,
DEPLOYED, NOT_DEPLOYED, DEPLOYMENT_PENDING, REMOVAL_PENDING,
)
......@@ -130,22 +130,22 @@ class DeploymentTest(BaseTestCase):
self.assertEqual(deployment.state, NOT_DEPLOYED)
def test_group_with_no_service(self):
deployment = get_deployment(self.user, self.group_none)
deployment = VODeployment.get_or_create(self.user, self.group_none)
self.deployment_run(deployment, 0)
def test_group_with_service(self):
deployment = get_deployment(self.user, vo=self.group_one)
deployment = VODeployment.get_or_create(self.user, self.group_one)
self.deployment_run(deployment, 1)
def test_group_with_two_services(self):
deployment = get_deployment(self.user, vo=self.group_two)
deployment = VODeployment.get_or_create(self.user, self.group_two)
self.deployment_run(deployment, 2)
# a vo with one service gets another service *after* the user requested the deployment
def test_group_with_delayed_service(self):
deployment = get_deployment(self.user, self.group_one)
deployment = VODeployment.get_or_create(self.user, self.group_one)
self.deployment_run_delayed_service(deployment, self.group_one, 1)
# a vo with two services loses one service *after* the user requested the deployment
def test_group_with_vanishing_service(self):
get_deployment(self.user, vo=self.group_two)
VODeployment.get_or_create(self.user, self.group_two)
......@@ -6,7 +6,7 @@ import copy
from django.urls import reverse
from feudal.backend.models import Service
from feudal.backend.models.users import User, SSHPublicKey
from feudal.backend.models.users import User, SSHPublicKey, UserPreferences
from feudal.backend.models.deployments import DEPLOYED, NOT_DEPLOYED, CredentialState, Deployment, DeploymentState
from feudal.backend.models.auth.vos import Group, Entitlement
......@@ -21,12 +21,28 @@ def test_user_deactivation(deployed_deployment, user):
user.deactivate()
assert not user.is_active
user.deactivate() # redo the deactivation
assert not user.is_active
user.activate()
assert user.is_active
user.activate() # redo the activation
assert user.is_active
# TODO add assertions for the user and dep and state
def test_user_userinfo_without_lists(idp, userinfo_without_lists):
user = User.construct_from_userinfo(userinfo_without_lists, idp)
assert user.vos.count() == 2 # non list vos should still be applied
def test_user_userinfo_invalid_vos(idp, userinfo_invalid_vos):
user = User.construct_from_userinfo(userinfo_invalid_vos, idp)
assert user.vos.count() == 0 # no vos applied
def test_user_key_after_deployment(deployed_deployment, idp, userinfo, downstream_test_client):
user = User.construct_from_userinfo(userinfo, idp)
assert user is not None
......@@ -243,3 +259,16 @@ def test_construct_from_userinfo(idp, userinfo):
}
user = User.construct_from_userinfo(userinfo, idp)
assert user.userinfo['test_value'] == test_value
assert user.preferences.deployment_mode == UserPreferences.DEPLOYMENT_MODE_BOTH
def test_user_deletion_cascade(user):
""" the user deletion must cascade to its preferences """
prefs_pk = user.preferences.pk
assert UserPreferences.objects.filter(pk=prefs_pk).exists()
user.delete()
assert not UserPreferences.objects.filter(pk=prefs_pk).exists()
......@@ -141,6 +141,13 @@ class User(AbstractUser):
user.update_userinfo(userinfo)
user.save()
_, createdPrefs = UserPreferences.objects.get_or_create(
user=user,
)
if createdPrefs:
LOGGER.info('construct_from_userinfo: Created preferences for user %s', user)
return user
@classmethod
......@@ -449,11 +456,7 @@ class User(AbstractUser):
def delete(self, using=None, keep_parents=False):
if self.user_type == self.TYPE_CHOICE_USER:
LOGGER.info('Deleting User %s', self)
# TODO
# these deletions are a hack. django should (TM) be able to delete them itself
# but there seems to be a bug
LOGGER.info(self.msg('Preparing user deletion'))
self._delete_deployments()
......@@ -461,9 +464,7 @@ class User(AbstractUser):
for key in self.ssh_keys.all():
key.delete_key()
for state in self.states.all():
state.publish_to_client()
LOGGER.info(self.msg('Deleting right now'))
super().delete(using, keep_parents)
def add_key(self, key):
......@@ -554,3 +555,29 @@ def activate_user(sender, instance=None, created=False, **kwargs):
if instance.is_active and not instance.is_active_at_clients:
instance.activate()
class UserPreferences(models.Model):
user = models.OneToOneField(
User,
related_name='preferences',
on_delete=models.CASCADE,
)
DEPLOYMENT_MODE_BOTH = 'both'
DEPLOYMENT_MODE_SERVICES_ONLY = 'services-only'
DEPLOYMENT_MODE_VOS_ONLY = 'vos-only'
DEPLOYMENT_MODE_DEFAULT = DEPLOYMENT_MODE_BOTH
DEPLOYMENT_MODE_CHOICES = (
(DEPLOYMENT_MODE_BOTH, 'Pick a combination of services and VOs for deployment.'),
(DEPLOYMENT_MODE_SERVICES_ONLY, 'Pick individual services for deployment'),
(DEPLOYMENT_MODE_VOS_ONLY, 'Pick individual VOs for deployment'),
)
deployment_mode = models.CharField(
max_length=20,
choices=DEPLOYMENT_MODE_CHOICES,
default=DEPLOYMENT_MODE_DEFAULT,
)
......@@ -93,43 +93,3 @@ class BaseTestCase(TestCase):
user=cls.user,
).save()
# only needed to manually tear down the test data
@classmethod
def manualTearDownTestData(cls):
# delete in reverse order
for member in [
'key',
'service_one',
'service_two_a',
'service_two_b',
'site',
'site2',
'user',
'group_none',
'group_one',
'group_two',
'idp',
]:
if hasattr(cls, member):
try:
getattr(cls, member).delete()
# pylint: disable=bare-except
except:
pass
# the user is logged in using session authentication
class LoggedInTest(BaseTestCase):
client = None
def setUp(self):
self.client = Client()
user = authenticate(
username=self.USER_NAME,
password=self.USER_PASSWORD,
)
self.assertIsNotNone(user)
self.client.force_login(
user=user,
)
......@@ -86,10 +86,6 @@ class ConfigurationView(views.APIView):
except VO.DoesNotExist:
service.vos.add(vo)
# we need to update the Deployments here
for dep in vo.vo_deployments.all():
dep.update()
# returns the service ID to service mapping contained in the request
def parse_sid_to_service(self, request):
self.sid_to_service = {}
......
......@@ -10,9 +10,11 @@ from rest_framework.permissions import AllowAny
from feudal.backend.views.renderers import PlainTextRenderer
from feudal.backend.models import Service
from feudal.backend.models.users import UserPreferences
from feudal.backend.models.serializers import (
UserStateSerializer, ServiceSerializer, SSHPublicKeySerializer,
DeploymentSerializer, DeploymentStateSerializer
DeploymentSerializer, DeploymentStateSerializer,
UserPreferencesSerializer
)
from feudal.backend.models.deployments import VODeployment, ServiceDeployment
from feudal.backend.models.auth.vos import VO
......@@ -83,6 +85,33 @@ class UserView(generics.RetrieveDestroyAPIView):
instance.delete()
class UserPreferencesView(generics.RetrieveUpdateAPIView):
permission_classes = PERMISSION_CLASSES
serializer_class = UserPreferencesSerializer
def get_object(self):
return self.request.user.preferences
def perform_update(self, serializer):
preferences = serializer.save()
LOGGER.debug(self.request.user.msg('Updated preferences'))
if preferences.deployment_mode == UserPreferences.DEPLOYMENT_MODE_SERVICES_ONLY:
qs = self.request.user.deployments.instance_of(VODeployment)
if qs.exists():
LOGGER.debug(self.request.user.msg('Removing my VODeployments because of a new preference'))
qs.delete()
elif preferences.deployment_mode == UserPreferences.DEPLOYMENT_MODE_VOS_ONLY:
qs = self.request.user.deployments.instance_of(ServiceDeployment)
if qs.exists():
LOGGER.debug(self.request.user.msg('Removing my ServiceDeployments because of a new preference'))
qs.delete()
class ServiceListView(generics.ListAPIView):
permission_classes = PERMISSION_CLASSES
serializer_class = ServiceSerializer
......@@ -96,8 +125,10 @@ class ServiceView(generics.RetrieveAPIView):
serializer_class = ServiceSerializer
def get_object(self):
available_services = Service.objects.filter(vos__user=self.request.user).distinct()
LOGGER.debug('Available services: %s', available_services)
return get_object_or_404(
Service.objects.filter(vos__user=self.request.user),
available_services,
id=self.kwargs['id'],
)
......@@ -180,14 +211,7 @@ class DeploymentView(generics.RetrieveUpdateAPIView):
if self.kwargs['type'] == 'vo':
try:
vo = self.request.user.vos.get(id=dep_id)
dep, created = VODeployment.objects.get_or_create(
user=self.request.user,
vo=vo,
defaults={},
)
if created:
LOGGER.debug('Created new VO deployment')
return dep
return VODeployment.get_or_create(self.request.user, vo)
except VO.DoesNotExist:
raise exceptions.ValidationError('You have no VO with id "{}"'.format(dep_id))
......@@ -197,23 +221,13 @@ class DeploymentView(generics.RetrieveUpdateAPIView):
service = None
for s in self.request.user.services:
if s.id == int(dep_id):
if service is None:
service = s
else:
raise Exception('user has multiple services with identical id')
service = s
break
if service is None:
raise exceptions.ValidationError('You are not permitted to access a service with id "{}"'.format(dep_id))
dep, created = ServiceDeployment.objects.get_or_create(
user=self.request.user,
service=service,
defaults={},
)
if created:
LOGGER.debug('Created new ServiceDeployment')
return dep
return ServiceDeployment.get_or_create(self.request.user, service)
raise exceptions.ValidationError('Type must be either "service" or "vo"')
......@@ -224,6 +238,8 @@ class DeploymentView(generics.RetrieveUpdateAPIView):
if dep.state_target != old_state_target:
dep.target_changed()
else:
LOGGER.debug(dep.msg('Patched but the state_target was not changed'))
class DeploymentStateListView(generics.ListCreateAPIView):
......@@ -265,22 +281,25 @@ class DeploymentStateView(generics.RetrieveUpdateAPIView):
URLPATTERNS = [
path('user', UserView.as_view(), name='user'),
path('services', ServiceListView.as_view(), name='user-services'),
path('user-prefs', UserPreferencesView.as_view(), name='user-prefs'),
path('services', ServiceListView.as_view(), name='user-service-list'),
re_path(r'^service/(?P<id>[0-9]+)$', ServiceView.as_view(), name='user-service'),
path('vos', VOListView.as_view(), name='user-vos'),
path('vos', VOListView.as_view(), name='user-vo-list'),
re_path(r'^vo/(?P<id>[0-9]+)$', VOView.as_view(), name='user-vo'),
path('ssh-keys', SSHPublicKeyListView.as_view(), name='user-sshkeys'),
re_path(r'^ssh-key/(?P<id>[0-9]+)$', SSHPublicKeyView.as_view(), name='user-sshkey'),
path('ssh-keys', SSHPublicKeyListView.as_view(), name='user-ssh-key-list'),
re_path(r'^ssh-key/(?P<id>[0-9]+)$', SSHPublicKeyView.as_view(), name='user-ssh-key'),
# type is either 'vo' or 'service'
re_path(r'^deployments/?(?P<type>.+)?$', DeploymentListView.as_view(), name='user-deps'),
re_path(r'^deployments/?(?P<type>.+)?$', DeploymentListView.as_view(), name='user-dep-list'),
re_path(r'^deployment/(?P<type>.+)/(?P<id>[0-9]+)$', DeploymentView.as_view(), name='user-dep'),
path('states', DeploymentStateListView.as_view(), name='user-dep-states'),
path('states', DeploymentStateListView.as_view(), name='user-dep-state-list'),
re_path(r'^state/(?P<id>[0-9]+)$', DeploymentStateView.as_view(), name='user-dep-state'),
# this catch all must be last in the list!
path('', HelpView.as_view(), name='user-help'),
re_path(r'.*$', HelpView.as_view()),
]
This diff is collapsed.
......@@ -118,7 +118,7 @@ HANDLER_BACKUP_COUNT = 3
# audit log
DEBUGX_LOG_LEVEL = 9 # less then debug
logging.addLevelName(DEBUGX_LOG_LEVEL, 'DEBUGX')
logging.addLevelName(DEBUGX_LOG_LEVEL, 'DEBUX')
AUDIT_LOG_LEVEL = 5 # lowest log level
logging.addLevelName(AUDIT_LOG_LEVEL, 'AUDIT')
......@@ -181,7 +181,7 @@ LOGGING = {
'filters': ['audit_only'], # display no other messages
},