models.py 24.4 KB
Newer Older
Lukas Burgey's avatar
Lukas Burgey committed
1
2
3
4
5
# django senders need their arguments
# pylint: disable=unused-argument

import json
import logging
Lukas Burgey's avatar
Lukas Burgey committed
6

Lukas Burgey's avatar
Lukas Burgey committed
7
from django.contrib.auth.models import AbstractUser, Group
8
from django.core.cache import cache
Lukas Burgey's avatar
Lukas Burgey committed
9
from django.db import models
Lukas Burgey's avatar
Lukas Burgey committed
10
from django.db.models.signals import post_save
Lukas Burgey's avatar
Lukas Burgey committed
11
from django.dispatch import receiver
12
from django_mysql.models import JSONField
Lukas Burgey's avatar
Lukas Burgey committed
13
14
15
16
from pika.exceptions import ConnectionClosed
import pika
from requests.auth import HTTPBasicAuth

Lukas Burgey's avatar
Lukas Burgey committed
17
from .auth.v1.models import OIDCConfig
Lukas Burgey's avatar
Lukas Burgey committed
18

Lukas Burgey's avatar
Lukas Burgey committed
19
LOGGER = logging.getLogger(__name__)
20
21

RABBITMQ_CONNECTION = None
22

23
24
25
26
27
28
29
30
STATE_CHOICES = (
    ('deployment_pending', 'Deployment Pending'),
    ('removal_pending', 'Removal Pending'),
    ('deployed', 'Deployed'),
    ('not_deployed', 'Not Deployed'),
    ('questionnaire', 'Questionnaire'),
)

Lukas Burgey's avatar
Lukas Burgey committed
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# singleton for simple configs
# https://steelkiwi.com/blog/practical-application-singleton-design-pattern/
class SingletonModel(models.Model):
    class Meta:
        abstract = True

    def set_cache(self):
        cache.set(self.__class__.__name__, self)

    # pylint: disable=invalid-name, arguments-differ
    def save(self, *args, **kwargs):
        self.pk = 1
        super(SingletonModel, self).save(*args, **kwargs)
        self.set_cache()

    @classmethod
    def load(cls):
        if cache.get(cls.__name__) is None:
            obj, created = cls.objects.get_or_create(pk=1)
            if not created:
                obj.set_cache()
        return cache.get(cls.__name__)

Lukas Burgey's avatar
Lukas Burgey committed
55
56
57
def exchanges_default():
    return []

58
59
60
61
62
63
64
65
66
67
68
69
70
# takes a list of states
# return '<state>' if all states are equal to '<state>'
# else it returns 'mixed'
def analyze_states(states):
    _state = ''
    for state in states:
        if _state == '':
            _state = state
        elif _state != state:
            return 'mixed'

    return _state

71

Lukas Burgey's avatar
Lukas Burgey committed
72
73
# clients are registerred at rabbitmq, when they are assigned to a site
# (because we only then know what services they provide)
74
class RabbitMQInstance(SingletonModel):
Lukas Burgey's avatar
Lukas Burgey committed
75
    host = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
76
77
78
        max_length=150,
        default='localhost',
    )
Lukas Burgey's avatar
Lukas Burgey committed
79
80
81
82
    vhost = models.CharField(
        max_length=150,
        default='%2f',
    )
Lukas Burgey's avatar
Lukas Burgey committed
83
84
85
86
    exchanges = JSONField(
        default=exchanges_default,
        null=True,
        blank=True,
Lukas Burgey's avatar
Lukas Burgey committed
87
    )
Lukas Burgey's avatar
Lukas Burgey committed
88
    port = models.IntegerField(
Lukas Burgey's avatar
Lukas Burgey committed
89
        default=15672,
Lukas Burgey's avatar
Lukas Burgey committed
90
    )
Lukas Burgey's avatar
Lukas Burgey committed
91
    username = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
92
93
94
        max_length=150,
        default='guest',
    )
Lukas Burgey's avatar
Lukas Burgey committed
95
    password = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
96
97
98
        max_length=150,
        default='guest',
    )
Lukas Burgey's avatar
Lukas Burgey committed
99
100
101
102

    def __str__(self):
        return self.host

Lukas Burgey's avatar
Lukas Burgey committed
103
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
104
        return '[RabbitMQ:{}] {}'.format(self.host, msg)
Lukas Burgey's avatar
Lukas Burgey committed
105
106
107
108

    @property
    def auth(self):
        return HTTPBasicAuth(
Lukas Burgey's avatar
Lukas Burgey committed
109
            self.username,
Lukas Burgey's avatar
Lukas Burgey committed
110
            self.password,
Lukas Burgey's avatar
Lukas Burgey committed
111
        )
Lukas Burgey's avatar
Lukas Burgey committed
112

113
114
    def _init_exchanges(self, channel):
        channel.exchange_declare(
Lukas Burgey's avatar
Lukas Burgey committed
115
116
117
118
119
120
121
            exchange='deployments',
            durable=True,
            auto_delete=False,
            exchange_type='topic',
        )
        channel.exchange_declare(
            exchange='sites',
122
123
124
125
126
127
128
129
130
131
132
133
134
            durable=True,
            auto_delete=False,
            exchange_type='topic',
        )
        channel.exchange_declare(
            exchange='update',
            durable=True,
            auto_delete=False,
            exchange_type='topic',
        )

    def _init_connection(self):
        global RABBITMQ_CONNECTION
135
        #LOGGER.debug('Opening new BlockingConnection')
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
        RABBITMQ_CONNECTION = pika.BlockingConnection(
            pika.ConnectionParameters(
                host=self.host,
                ssl=True,
                heartbeat_interval=60,
            )
        )
        return RABBITMQ_CONNECTION

    @property
    def _connection(self):
        global RABBITMQ_CONNECTION
        if RABBITMQ_CONNECTION is not None:
            if RABBITMQ_CONNECTION.is_open:
                return RABBITMQ_CONNECTION
            elif RABBITMQ_CONNECTION.is_closing:
                RABBITMQ_CONNECTION.close()

        connection = self._init_connection()
        channel = connection.channel()
        self._init_exchanges(connection.channel())
        channel.close()
        RABBITMQ_CONNECTION = connection
        return connection

Lukas Burgey's avatar
Lukas Burgey committed
161
    @property
162
    def _channel(self):
Lukas Burgey's avatar
Lukas Burgey committed
163
164
165
166
167
168
169
170
        try:
            channel = self._connection.channel()
            channel.confirm_delivery()
            return channel
        except ConnectionClosed as exception:
            LOGGER.error(self.msg('ConnectionClosed: ' + str(exception)))
            self._init_connection()
            return self._channel
171
172
173

    def _publish(self, exchange, routing_key, body):
        channel = self._channel
174
        channel.basic_publish(
175
176
177
178
179
180
            exchange=exchange,
            routing_key=routing_key,
            body=body,
            properties=pika.BasicProperties(
                delivery_mode=1,
            ),
Lukas Burgey's avatar
Lukas Burgey committed
181
        )
182
        channel.close()
183

184
    # PUBLIC API
185
    def publish_by_service(self, service, msg):
186
        self._publish(
Lukas Burgey's avatar
Lukas Burgey committed
187
            'deployments',
188
189
190
            service.routing_key,
            msg,
        )
Lukas Burgey's avatar
Lukas Burgey committed
191

Lukas Burgey's avatar
Lukas Burgey committed
192
193
194
195
196
197
198
    def publish_by_site(self, site, msg):
        self._publish(
            'sites',
            site.name,
            msg,
        )

199
    def publish_to_webpage(self, user, msg):
200
201
        # noise
        # LOGGER.debug('Signalling webpage of user %s', user)
202
203
204
        self._publish(
            'update',
            str(user.id),
205
            json.dumps(msg),
206
        )
207

Lukas Burgey's avatar
Lukas Burgey committed
208

209
210
def user_info_default():
    return {}
211
212
213
214
def questionnaire_default():
    return {}
def credential_default():
    return {}
215
216


Lukas Burgey's avatar
Lukas Burgey committed
217
class User(AbstractUser):
218
    TYPE_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
219
220
221
222
        ('apiclient', 'API-Client'),
        ('oidcuser', 'OIDC User'),
        ('admin', 'Admin'),
    )
223
    user_type = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
224
225
        max_length=20,
        choices=TYPE_CHOICES,
Lukas Burgey's avatar
Lukas Burgey committed
226
        default='apiclient',
Lukas Burgey's avatar
Lukas Burgey committed
227
    )
228
229
230
231
    sub = models.CharField(
        max_length=150,
        blank=True,
        null=True,
232
        editable=False,
233
234
235
236
237
238
239
240
241
242
243
244
    )
    password = models.CharField(
        max_length=150,
        blank=True,
        null=True,
    )
    # the real state of the user
    # (self.is_active is the supposed state of the user)
    _is_active = models.BooleanField(
        default=True,
        editable=False,
    )
Lukas Burgey's avatar
Lukas Burgey committed
245
246
247
248
249
    # the idp which authenticated the user
    idp = models.ForeignKey(
        OIDCConfig,
        related_name='users',
        on_delete=models.CASCADE,
250
251
252
        blank=True,
        null=True,
        editable=False,
Lukas Burgey's avatar
Lukas Burgey committed
253
    )
254
255
256
257
    userinfo = JSONField(
        default=user_info_default,
        null=True,
        blank=True,
258
        editable=False,
259
    )
Lukas Burgey's avatar
Lukas Burgey committed
260

Lukas Burgey's avatar
Lukas Burgey committed
261
262
263
264
    # returns the user as identified by userinfo and idp
    # if the user does not exists
    @classmethod
    def get_user(cls, userinfo, idp):
Lukas Burgey's avatar
Lukas Burgey committed
265
        if 'sub' not in userinfo:
Lukas Burgey's avatar
Lukas Burgey committed
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
            raise Exception('get_user needs a userinfo which contains the users subject')

        query_result = cls.objects.filter(
            sub=userinfo['sub'],
            idp=idp,
        )
        if not query_result.exists():
            return cls.construct_from_userinfo(userinfo, idp)

        if len(query_result) > 1:
            return Exception('Two user instances with same subject from the same idp')

        # TODO update the users userinfo when it changes
        # TODO update the users groupinfo when it changes
        return query_result.first()

    @classmethod
    def construct_from_userinfo(cls, userinfo, idp):
284
        LOGGER.debug('Constructing User from:\n%s', userinfo)
Lukas Burgey's avatar
Lukas Burgey committed
285
286
287
288
289
290

        if 'sub' not in userinfo:
            raise Exception('Missing attribute in userinfo: sub')
        sub = userinfo['sub']

        if 'email' not in userinfo:
291
            username = sub
Lukas Burgey's avatar
Lukas Burgey committed
292
293
        else:
            username = userinfo['email']
294
            email = userinfo['email']
Lukas Burgey's avatar
Lukas Burgey committed
295
296
297
298
299

        user = cls(
            user_type='oidcuser',
            username=username,
            sub=sub,
300
            email=email,
Lukas Burgey's avatar
Lukas Burgey committed
301
302
303
304
            idp=idp,
            userinfo=userinfo,
        )
        user.save()
Lukas Burgey's avatar
Lukas Burgey committed
305
306
307
308
309
310

        for group in idp.get_user_groupinformation(
                userinfo,
        ).all():
            group.users.add(user)

Lukas Burgey's avatar
Lukas Burgey committed
311
312
313
314
315
316
317
318
319
320
321
322
        return user

    @classmethod
    def construct_client(cls, username, password):
        LOGGER.debug('APICLIENT: new client %s', username)
        client = cls(
            username=username,
            user_type='apiclient',
        )
        client.set_password(password)
        return client

Lukas Burgey's avatar
Lukas Burgey committed
323
    # we hide deleted keys here
324
    # the full list of ssh keys is self._ssh_keys
Lukas Burgey's avatar
Lukas Burgey committed
325
326
327
328
    @property
    def ssh_keys(self):
        return self._ssh_keys.filter(deleted=False)

329
330
331
332
    @property
    def is_active_at_clients(self):
        return self._is_active

333
334
335
    def __str__(self):
        if self.user_type == 'admin':
            return 'ADMIN {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
336
        elif self.user_type == 'oidcuser':
337
338
339
            if not self.is_active:
                return 'DEACTIVATED USER {}'.format(self.username)
            return 'USER {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
340
        elif self.user_type == 'apiclient':
341
342
343
344
345
            try:
                return 'APICLIENT {}@{}'.format(self.username, self.site)
            except:
                return 'APICLIENT {}'.format(self.username)

Lukas Burgey's avatar
Lukas Burgey committed
346
347
        else:
            raise Exception()
Lukas Burgey's avatar
Lukas Burgey committed
348

Lukas Burgey's avatar
Lukas Burgey committed
349
    def msg(self, msg):
350
351
352
353
354
355
356
        return '[{}] {}'.format(self, msg)

    # oidcuser: withdraw and delete all credentials and delete the user
    def remove(self):
        if self.user_type == 'oidcuser':
            self.deactivate()

Lukas Burgey's avatar
Lukas Burgey committed
357
            # FIXME: deleting the user brings problems:
Lukas Burgey's avatar
Lukas Burgey committed
358
            # the deletion cascades down to DeploymentState and DeploymentStateItem
359
            # but these need to be conserved so all clients removals can be tracked
Lukas Burgey's avatar
Lukas Burgey committed
360
            LOGGER.info(self.msg('Deleting'))
361
362
363
            self.delete()

    def activate(self):
364
        if self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
365
            LOGGER.error(self.msg('already activated'))
366
367
            return

Lukas Burgey's avatar
Lukas Burgey committed
368
369
370
371
        self.is_active = True
        self._is_active = True
        self.save()
        LOGGER.info(self.msg('activated'))
372

Lukas Burgey's avatar
Lukas Burgey committed
373
374
        # oidcuser: deploy the according credentials
        if self.user_type == 'oidcuser':
375
376
377
378
            for dep in self.deployments.all():
                dep.activate()

    def deactivate(self):
379
        if not self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
380
            LOGGER.error(self.msg('already deactivated'))
381
382
            return

Lukas Burgey's avatar
Lukas Burgey committed
383
384
385
386
387
388
        self.is_active = False
        self._is_active = False
        self.save()
        LOGGER.info(self.msg('deactivated'))

        # oidcuser: withdraw all credentials
389
390
391
392
393
        if self.user_type == 'oidcuser':

            for dep in self.deployments.all():
                dep.deactivate()

Lukas Burgey's avatar
Lukas Burgey committed
394

Lukas Burgey's avatar
Lukas Burgey committed
395
396
397
398
399
400
401
402
403
404
405
406
# authorisation groups
class AuthGroup(models.Model):
    name = models.CharField(
        max_length=200,
    )
    users = models.ManyToManyField(
        User,
        related_name='auth_groups',
        blank=True,
    )


Lukas Burgey's avatar
Lukas Burgey committed
407
class Site(models.Model):
408
    client = models.OneToOneField(
Lukas Burgey's avatar
Lukas Burgey committed
409
410
        User,
        related_name='site',
411
412
413
414
415
416
417
418
419
420
421
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )
    name = models.CharField(
        max_length=150,
        unique=True,
    )
    description = models.TextField(
        max_length=300,
        blank=True,
Lukas Burgey's avatar
Lukas Burgey committed
422
    )
Lukas Burgey's avatar
Lukas Burgey committed
423
424
425
426
427
428

    def __str__(self):
        return self.name


class Service(models.Model):
429
430
431
432
433
434
435
436
    name = models.CharField(
        max_length=150,
        unique=True,
    )
    description = models.TextField(
        max_length=300,
        blank=True,
    )
437
    site = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
438
        Site,
439
440
        related_name='services',
    )
Lukas Burgey's avatar
Lukas Burgey committed
441
    groups = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
442
443
        Group,
        related_name='services',
444
445
        blank=True,
    )
Lukas Burgey's avatar
Lukas Burgey committed
446

447
448
449
450
    @property
    def routing_key(self):
        return 'service.{}'.format(self.name)

Lukas Burgey's avatar
Lukas Burgey committed
451
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
452
        return self.name
Lukas Burgey's avatar
Lukas Burgey committed
453
454
455


class SSHPublicKey(models.Model):
Lukas Burgey's avatar
Lukas Burgey committed
456
457
458
459
460
461
    name = models.CharField(
        max_length=150,
    )
    key = models.TextField(
        max_length=1000
    )
Lukas Burgey's avatar
Lukas Burgey committed
462
    # hidden field at the user
463
    # TODO checks: if the user is null
Lukas Burgey's avatar
Lukas Burgey committed
464
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
465
466
        User,
        related_name='_ssh_keys',
467
468
        on_delete=models.SET_NULL,
        null=True,
Lukas Burgey's avatar
Lukas Burgey committed
469
    )
Lukas Burgey's avatar
Lukas Burgey committed
470

471
    # has the user triggered the deletion of this key?
Lukas Burgey's avatar
Lukas Burgey committed
472
    deleted = models.BooleanField(
Lukas Burgey's avatar
Lukas Burgey committed
473
474
475
        default=False,
        editable=False,
    )
476

477
478
479
480
    def states(self):
        states = []
        for state in self.states.all():
            states.append(state.states)
481

482
483
484
    # does not directly delete the key if the key is deployed or withdrawn
    # somewhere
    # the receiver 'delete_withdrawn_ssh_key' does the actual deletion
485
    def delete_key(self):
486
487
        # if this key is not deployed anywhere we delete it now
        if analyze_states(self.states) == 'not_deployed':
Lukas Burgey's avatar
Lukas Burgey committed
488
            LOGGER.info(self.msg('Direct deletion of key'))
489
490
491
            self.delete()
            return

Lukas Burgey's avatar
Lukas Burgey committed
492
        LOGGER.info(self.msg('Deletion of key started'))
493
494
495
        self.deleted = True
        self.save()

Lukas Burgey's avatar
Lukas Burgey committed
496
        # delete implies withdrawing the key from all clients
497
        for deployment in self.deployments.all():
498
            deployment.remove_key(self)
499

Lukas Burgey's avatar
Lukas Burgey committed
500
501
    # when a key is withdrawn by a client we try to finally delete it
    def try_final_deletion(self):
Lukas Burgey's avatar
Lukas Burgey committed
502
        if (self.deleted and not self.states.exists()):
503
            LOGGER.info(self.msg( 'All clients have withdrawn this key. Final deletion'))
Lukas Burgey's avatar
Lukas Burgey committed
504
505
506
            self.delete()
            return

Lukas Burgey's avatar
Lukas Burgey committed
507
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
508
509
        if self.deleted:
            return "DELETED: {}".format(self.name)
Lukas Burgey's avatar
Lukas Burgey committed
510
511
        return self.name

512
513
514
    def msg(self, msg):
        return '[SSHKey:{}] {}'.format(self, msg)

Lukas Burgey's avatar
Lukas Burgey committed
515

516
# Deployment describes the credential state per user (and site) as it is supposed to be
517
518
519
520
#
# (exception: if is_active=False the ssh_keys contain the keys to be deployed
# if the deployment is reactivated)
#
Lukas Burgey's avatar
Lukas Burgey committed
521
522
# DeploymentState is what is sent to the clients via rabbitmq
# The DeploymentStateItem track the acknowledgements from the clients
Lukas Burgey's avatar
Lukas Burgey committed
523
524
class Deployment(models.Model):
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
525
526
527
528
        User,
        related_name='deployments',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
529
    service = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
530
531
532
533
        Service,
        related_name='deployments',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
534
    ssh_keys = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
535
536
537
538
        SSHPublicKey,
        related_name='deployments',
        blank=True,
    )
539
    ssh_keys_to_withdraw = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
540
541
542
543
        SSHPublicKey,
        related_name='withdrawn_deployments',
        blank=True,
    )
544
    is_active = models.BooleanField(
Lukas Burgey's avatar
Lukas Burgey committed
545
546
        default=True,
    )
547

Lukas Burgey's avatar
Lukas Burgey committed
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
    # get a deployment for a user/service.
    # if it does not exist it is created
    @classmethod
    def get_deployment(cls, user, service):
        query = cls.objects.filter(
            user=user,
            service=service,
        )
        if query.exists():
            return query.first()

        deployment = cls(
            user=user,
            service=service,
        )
        deployment.save()
564
        LOGGER.debug(deployment.msg('created'))
Lukas Burgey's avatar
Lukas Burgey committed
565
566
        return deployment

567
568
569
    # deploy credentials which were deployed prior to deactivation
    def activate(self):
        if self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
570
            LOGGER.error(self.msg('already active'))
571
572
            return

Lukas Burgey's avatar
Lukas Burgey committed
573
        LOGGER.debug(self.msg(str(self.ssh_keys.all())))
574
575
576
577
578
        for key in self.ssh_keys.all():
            self._deploy_key(key)

        self.is_active = True
        self.save()
Lukas Burgey's avatar
Lukas Burgey committed
579
        LOGGER.info(self.msg('activated'))
580
581
582
583

    # withdraw all credentials
    def deactivate(self):
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
584
            LOGGER.error(self.msg('already deactivated'))
585
586
587
            return

        self.is_active = False
588
        self.save()
589

590
        for key in self.ssh_keys.all():
591
            self._remove_key(key)
592

Lukas Burgey's avatar
Lukas Burgey committed
593
        LOGGER.info(self.msg('deactivated'))
594
595
596
597

    # deploy key and track changes in the key lists
    def deploy_key(self, key):
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
598
            LOGGER.error(self.msg('cannot deploy while deactivated'))
599
600
601
602
603
604
605
606
607
608
609
            raise Exception('deployment deactivated')

        self.ssh_keys.add(key)

        if key in self.ssh_keys_to_withdraw.all():
            self.ssh_keys_to_withdraw.remove(key)
        self.save()

        self._deploy_key(key)

    # withdraw key and track changes in the key lists
610
    def remove_key(self, key):
611
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
612
            LOGGER.error(self.msg('cannot withdraw while deactivated'))
613
614
615
616
617
618
619
620
            raise Exception('deployment deactivated')

        self.ssh_keys.remove(key)

        # keys which are to be withdrawn by the clients
        self.ssh_keys_to_withdraw.add(key)
        self.save()

621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
        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()
639

640
641
    def __str__(self):
        return '{}:{}'.format(self.service, self.user)
Lukas Burgey's avatar
Lukas Burgey committed
642

643
644
    def msg(self, msg):
        return '[Deployment:{}] {}'.format(self, msg)
645
646


Lukas Burgey's avatar
Lukas Burgey committed
647
# DeploymentState: knows:
648
# user, service, key, state_target
Lukas Burgey's avatar
Lukas Burgey committed
649
class DeploymentState(models.Model):
650
    key = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
651
        SSHPublicKey,
Lukas Burgey's avatar
Lukas Burgey committed
652
        related_name='states',
653
654
        # deleting the key leaves us without references about its deployments
        # we _HAVE_ to remove all deployments prior to deleting key
Lukas Burgey's avatar
Lukas Burgey committed
655
656
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
657
    deployment = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
658
        Deployment,
Lukas Burgey's avatar
Lukas Burgey committed
659
        related_name='states',
Lukas Burgey's avatar
Lukas Burgey committed
660
661
        on_delete=models.CASCADE,
    )
662
663
    # TODO is this relation needed?
    # State does relate to Deployment which relates to User
664
665
    user = models.ForeignKey(
        User,
Lukas Burgey's avatar
Lukas Burgey committed
666
        related_name='deployment_states',
667
668
        # TODO deleting the user leaves us without references about its deployments
        # we _HAVE_ to remove all deployments prior to deleting site user
669
670
        on_delete=models.CASCADE,
    )
671
672
673
674
675
676
    # which state do we currently want to reach?
    state_target = models.CharField(
        max_length=50,
        choices=STATE_CHOICES,
        default='deployed',
    )
Lukas Burgey's avatar
Lukas Burgey committed
677

678
679
680
    @property
    def states(self):
        return [item.state for item in self.state_items.all()]
681

682
683
684
    @property
    def state(self):
        return analyze_states(self.states)
685

686
687
688
    @property
    def service(self):
        return self.deployment.service
Lukas Burgey's avatar
Lukas Burgey committed
689

690
691
692
    @property
    def target_reached(self):
        return self.state_target == self.state
Lukas Burgey's avatar
Lukas Burgey committed
693

694
695
    # get a deployment for a user/service.
    # if it does not exist it is created
Lukas Burgey's avatar
Lukas Burgey committed
696
    @classmethod
697
698
699
700
701
702
    def get_state(cls, deployment, key):
        # check if a state does already exist
        query = cls.objects.filter(
            deployment=deployment,
            key=key,
        )
703
        if query.exists():
704
            return query.first()
705

706
707
708
709
710
711
712
713
        # create new state if not
        state = cls(
            deployment=deployment,
            user=deployment.user,
            key=key,
        )
        state.save()
        LOGGER.debug(state.msg('created'))
714

715
716
717
718
        # generate state items
        for site in deployment.service.site.all():
            deploy = DeploymentStateItem(
                parent=state,
719
                user=deployment.user,
720
                site=site,
721
            )
722
            deploy.save()
723

724
        return state
Lukas Burgey's avatar
Lukas Burgey committed
725

726
727
728
729
730
731
    def deploy(self):
        self._set_target('deployed')
        for item in self.state_items.all():
            item.user_deploy()
        self.publish_to_client()
        self.publish_to_user()
732

733
734
735
736
737
738
    def remove(self):
        self._set_target('not_deployed')
        for item in self.state_items.all():
            item.user_remove()
        self.publish_to_client()
        self.publish_to_user()
739

740
    def publish_to_client(self):
Lukas Burgey's avatar
Lukas Burgey committed
741
        # mitigating circular dependencies here
Lukas Burgey's avatar
Lukas Burgey committed
742
743
        from .clientapi.serializers import DeploymentStateSerializer
        msg = json.dumps(DeploymentStateSerializer(self).data)
744

745
        RabbitMQInstance.load().publish_by_service(
Lukas Burgey's avatar
Lukas Burgey committed
746
747
748
            self.service,
            msg,
        )
749
    # update the state of the remote webpage
750
    def publish_to_user(self):
751
752
753
754
755
756
757
758
759
        from .frontend.views import user_state_dict
        content = {
            'user_state': user_state_dict(self.user),
        }
        RabbitMQInstance.load().publish_to_webpage(
            self.user,
            content,
        )

760
761
    def msg(self, msg):
        return '[DState:{}] {}'.format(self, msg)
762

763
764
765
766
    def _set_target(self, target):
        self.state_target = target
        LOGGER.debug(self.msg('target: '+target))
        self.save()
767

768
769
770
771
    def __str__(self):
        return "{}#{}".format(
            self.deployment.service,
            self.id,
772
773
        )

774

Lukas Burgey's avatar
Lukas Burgey committed
775
# DeploymentStateItem: knows:
776
# user, service, key, state_target, _and_ site
Lukas Burgey's avatar
Lukas Burgey committed
777
class DeploymentStateItem(models.Model):
778
    parent = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
779
780
        DeploymentState,
        related_name='state_items',
Lukas Burgey's avatar
Lukas Burgey committed
781
782
        on_delete=models.CASCADE,
    )
783
    site = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
784
        Site,
Lukas Burgey's avatar
Lukas Burgey committed
785
        related_name='state_items',
786
787
        # TODO deleting the site leaves us without references about its deployments
        # we _HAVE_ to remove all deployments prior to deleting a site
Lukas Burgey's avatar
Lukas Burgey committed
788
789
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
790
791
    user = models.ForeignKey(
        User,
Lukas Burgey's avatar
Lukas Burgey committed
792
        related_name='deployment_state_items',
793
794
        # TODO deleting the user leaves us without references about its deployments
        # we _HAVE_ to remove all deployments prior to deleting site user
Lukas Burgey's avatar
Lukas Burgey committed
795
796
        on_delete=models.CASCADE,
    )
797
    state = models.CharField(
798
        max_length=50,
799
        choices=STATE_CHOICES,
800
        default='deployment_pending',
801
    )
Lukas Burgey's avatar
Lukas Burgey committed
802

803
    # questions for the user (needed for deployment
804
805
806
807
808
    questionnaire = JSONField(
        default=questionnaire_default,
        null=True,
        blank=True,
    )
809
810
    # credentials for the service
    # only valid when state == deployed
811
812
813
814
815
816
    credentials = JSONField(
        default=credential_default,
        null=True,
        blank=True,
    )

Lukas Burgey's avatar
Lukas Burgey committed
817
818
    @property
    def service(self):
819
        return self.parent.service
Lukas Burgey's avatar
Lukas Burgey committed
820
821
822

    @property
    def key(self):
823
        return self.parent.key
Lukas Burgey's avatar
Lukas Burgey committed
824
825

    @property
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
    def state_target(self):
        if (
                self.state  == 'deployment_pending'
                or self.state == 'questionnaire'
        ):
            return 'deploy'
        elif self.state == 'removal_pending':
            return 'withdraw'

        # no state_target is executed
        return 'none'


    # STATE transitions
    # user: deployment requested
    def user_deploy(self):
        if self.state == 'removal_pending':
            self._set_state('deployed')
            return
845

846
        self._set_state('deployment_pending')
847

848
849
850
851
852
853
854
855
    # user: removal requested
    def user_remove(self):
        if (
                self.state == 'deployment_pending'
                or self.state == 'questionnaire'
        ):
            self._set_state('not_deployed')
            return
856

857
        self._set_state('removal_pending')
858

859
860
861
862
    # user: questionnaire answered
    def user_answers(self, answers=None):
        self.questionnaire = answers
        self._set_state('deployment_pending')
863

864
865
866
867
    # client: deployed
    def client_deployed(self, credentials=None):
        self.credentials = credentials
        self._set_state('deployed')
868

869
870
871
    # client: removed
    def client_removed(self):
        self._set_state('not_deployed')
872

873
874
    # client: questionnaire
    def client_questionnaire(self, questionnaire=None):
875
        self.questionnaire = questionnaire
876
        self._set_state('questionnaire')
Lukas Burgey's avatar
Lukas Burgey committed
877
878
879
880

    # only used when we got a questionnaire_answered
    def publish(self):
        # mitigating circular dependencies here
Lukas Burgey's avatar
Lukas Burgey committed
881
882
        from .clientapi.serializers import DeploymentStateSerializer
        data = DeploymentStateSerializer(self.state).data
Lukas Burgey's avatar
Lukas Burgey committed
883
884
885
886
887
888
889
        data['questionnaire'] = self.questionnaire

        RabbitMQInstance.load().publish_by_site(
            self.site,
            json.dumps(data),
        )

890
891
892
893
894
895
896
897
898
    def msg(self, msg):
        return '[DSItem:{}] {}'.format(self, msg)

    def _set_state(self, state):
        self.state = state
        self.save()
        LOGGER.debug(self.msg('state: '+self.state))
        self.parent.publish_to_user()

Lukas Burgey's avatar
Lukas Burgey committed
899

Lukas Burgey's avatar
Lukas Burgey committed
900
    def __str__(self):
901
        return "{}@{}#{}".format(
902
            self.parent.service,
Lukas Burgey's avatar
Lukas Burgey committed
903
            self.site,
904
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
905
        )
906
907
908
909
910


#
# RECEIVERS
#
Lukas Burgey's avatar
Lukas Burgey committed
911

912
913
914
915
916
@receiver(post_save, sender=User)
def deactivate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

917
    if not instance.is_active and instance.is_active_at_clients:
918
919
920
921
922
923
924
925
        instance.deactivate()


@receiver(post_save, sender=User)
def activate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

926
    if instance.is_active and not instance.is_active_at_clients:
927
        instance.activate()