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

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

Lukas Burgey's avatar
Lukas Burgey committed
17
LOGGER = logging.getLogger(__name__)
18
19
RECONNECT_TIMEOUT = 5
RECONNECT_RETRIES = 3
20

Lukas Burgey's avatar
Lukas Burgey committed
21

22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 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
46
47
# clients are registerred at rabbitmq, when they are assigned to a site
# (because we only then know what services they provide)
48
class RabbitMQInstance(SingletonModel):
Lukas Burgey's avatar
Lukas Burgey committed
49
    host = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
50
51
52
        max_length=150,
        default='localhost',
    )
Lukas Burgey's avatar
Lukas Burgey committed
53
54
55
56
    vhost = models.CharField(
        max_length=150,
        default='%2f',
    )
Lukas Burgey's avatar
Lukas Burgey committed
57
    exchange = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
58
59
60
        max_length=150,
        default='deployments',
    )
Lukas Burgey's avatar
Lukas Burgey committed
61
    port = models.IntegerField(
Lukas Burgey's avatar
Lukas Burgey committed
62
        default=15672,
Lukas Burgey's avatar
Lukas Burgey committed
63
    )
Lukas Burgey's avatar
Lukas Burgey committed
64
    username = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
65
66
67
        max_length=150,
        default='guest',
    )
Lukas Burgey's avatar
Lukas Burgey committed
68
    password = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
69
70
71
        max_length=150,
        default='guest',
    )
Lukas Burgey's avatar
Lukas Burgey committed
72
73
74
75

    def __str__(self):
        return self.host

Lukas Burgey's avatar
Lukas Burgey committed
76
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
77
        return '[RabbitMQ:{}] {}'.format(self.host, msg)
Lukas Burgey's avatar
Lukas Burgey committed
78
79
80
81

    @property
    def auth(self):
        return HTTPBasicAuth(
Lukas Burgey's avatar
Lukas Burgey committed
82
            self.username,
Lukas Burgey's avatar
Lukas Burgey committed
83
            self.password,
Lukas Burgey's avatar
Lukas Burgey committed
84
        )
Lukas Burgey's avatar
Lukas Burgey committed
85
86

    @property
Lukas Burgey's avatar
Lukas Burgey committed
87
88
89
90
91
    def _connection_parameters(self):
        return pika.ConnectionParameters(
            host=self.host,
            ssl=True,
        )
92

Lukas Burgey's avatar
Lukas Burgey committed
93
94
    # PUBLIC API

95
    def publish_by_service(self, service, msg):
96
97
98
        # FIXME dirty
        tries = 0
        while tries < RECONNECT_RETRIES:
99
            try:
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
                # open connection
                connection = pika.BlockingConnection(
                    self._connection_parameters,
                )

                # open channel
                channel = connection.channel()
                channel.exchange_declare(
                    exchange=self.exchange,
                    durable=True,
                    auto_delete=False,
                    exchange_type='topic',
                )
                channel.confirm_delivery()

                channel.basic_publish(
116
117
118
119
120
121
122
                    exchange=self.exchange,
                    routing_key=service.routing_key,
                    body=msg,
                    properties=pika.BasicProperties(
                        delivery_mode=1,
                    ),
                )
123
124
125
126
                channel.close()
                connection.close()
                return
            except:
127
                time.sleep(RECONNECT_TIMEOUT)
Lukas Burgey's avatar
Lukas Burgey committed
128

129
130
            tries += 1

Lukas Burgey's avatar
Lukas Burgey committed
131

132
133
134
135
def user_info_default():
    return {}


Lukas Burgey's avatar
Lukas Burgey committed
136
class User(AbstractUser):
137
    TYPE_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
138
139
140
141
        ('apiclient', 'API-Client'),
        ('oidcuser', 'OIDC User'),
        ('admin', 'Admin'),
    )
142
    user_type = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
143
144
        max_length=20,
        choices=TYPE_CHOICES,
Lukas Burgey's avatar
Lukas Burgey committed
145
        default='apiclient',
Lukas Burgey's avatar
Lukas Burgey committed
146
    )
147
148
149
150
    sub = models.CharField(
        max_length=150,
        blank=True,
        null=True,
151
        editable=False,
152
153
154
155
156
157
158
159
160
161
162
163
    )
    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
164
165
166
167
168
    # the idp which authenticated the user
    idp = models.ForeignKey(
        OIDCConfig,
        related_name='users',
        on_delete=models.CASCADE,
169
170
171
        blank=True,
        null=True,
        editable=False,
Lukas Burgey's avatar
Lukas Burgey committed
172
    )
173
174
175
176
    userinfo = JSONField(
        default=user_info_default,
        null=True,
        blank=True,
177
        editable=False,
178
    )
Lukas Burgey's avatar
Lukas Burgey committed
179

Lukas Burgey's avatar
Lukas Burgey committed
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
    # returns the user as identified by userinfo and idp
    # if the user does not exists
    @classmethod
    def get_user(cls, userinfo, idp):
        if not 'sub' in userinfo:
            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):
        LOGGER.debug('User: constructing from %s', userinfo)

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

        if 'email' not in userinfo:
            if 'name' not in userinfo:
                raise Exception('Missing attributes in userinfo: email and name')

            username = userinfo['name']
        else:
            username = userinfo['email']

        user = cls(
            user_type='oidcuser',
            username=username,
            sub=sub,
            idp=idp,
            userinfo=userinfo,
        )
        user.save()
Lukas Burgey's avatar
Lukas Burgey committed
225
226
227
228
229
230

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

Lukas Burgey's avatar
Lukas Burgey committed
231
232
233
234
235
236
237
238
239
240
241
242
        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
243
    # we hide deleted keys here
244
    # the full list of ssh keys is self._ssh_keys
Lukas Burgey's avatar
Lukas Burgey committed
245
246
247
248
    @property
    def ssh_keys(self):
        return self._ssh_keys.filter(deleted=False)

249
250
251
252
    @property
    def is_active_at_clients(self):
        return self._is_active

253
254
255
    def __str__(self):
        if self.user_type == 'admin':
            return 'ADMIN {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
256
        elif self.user_type == 'oidcuser':
257
258
259
            if not self.is_active:
                return 'DEACTIVATED USER {}'.format(self.username)
            return 'USER {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
260
        elif self.user_type == 'apiclient':
261
262
263
264
265
            try:
                return 'APICLIENT {}@{}'.format(self.username, self.site)
            except:
                return 'APICLIENT {}'.format(self.username)

Lukas Burgey's avatar
Lukas Burgey committed
266
267
        else:
            raise Exception()
Lukas Burgey's avatar
Lukas Burgey committed
268

Lukas Burgey's avatar
Lukas Burgey committed
269
    def msg(self, msg):
270
271
272
273
274
275
276
        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
277
            # FIXME: deleting the user brings problems:
278
279
            # the deletion cascades down to DeploymentTask and DeploymentTaskItem
            # but these need to be conserved so all clients withdrawals can be tracked
Lukas Burgey's avatar
Lukas Burgey committed
280
            LOGGER.info(self.msg('Deleting'))
281
282
283
            self.delete()

    def activate(self):
284
        if self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
285
            LOGGER.error(self.msg('already activated'))
286
287
            return

Lukas Burgey's avatar
Lukas Burgey committed
288
289
290
291
        self.is_active = True
        self._is_active = True
        self.save()
        LOGGER.info(self.msg('activated'))
292

Lukas Burgey's avatar
Lukas Burgey committed
293
294
        # oidcuser: deploy the according credentials
        if self.user_type == 'oidcuser':
295
296
297
298
299
            for dep in self.deployments.all():
                dep.activate()


    def deactivate(self):
300
        if not self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
301
            LOGGER.error(self.msg('already deactivated'))
302
303
            return

Lukas Burgey's avatar
Lukas Burgey committed
304
305
306
307
308
309
        self.is_active = False
        self._is_active = False
        self.save()
        LOGGER.info(self.msg('deactivated'))

        # oidcuser: withdraw all credentials
310
311
312
313
314
        if self.user_type == 'oidcuser':

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

Lukas Burgey's avatar
Lukas Burgey committed
315

Lukas Burgey's avatar
Lukas Burgey committed
316
317
318
319
320
321
322
323
324
325
326
327
# 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
328
class Site(models.Model):
329
    client = models.OneToOneField(
Lukas Burgey's avatar
Lukas Burgey committed
330
331
        User,
        related_name='site',
332
333
334
335
336
337
338
339
340
341
342
        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
343
    )
Lukas Burgey's avatar
Lukas Burgey committed
344
345
346
347

    def __str__(self):
        return self.name

348
349
350
351
352
353
    # tasks which are still to be executed on this site
    @property
    def tasks(self):
        return [item.task
                for item
                in self.task_items.all()]
Lukas Burgey's avatar
Lukas Burgey committed
354

Lukas Burgey's avatar
Lukas Burgey committed
355
356
357
358

class Service(models.Model):
    name = models.CharField(max_length=150, unique=True)
    description = models.TextField(max_length=300, blank=True)
359
    site = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
360
361
        Site,
        related_name='services')
Lukas Burgey's avatar
Lukas Burgey committed
362
    groups = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
363
364
365
        Group,
        related_name='services',
        blank=True)
Lukas Burgey's avatar
Lukas Burgey committed
366

367
368
369
370
    @property
    def routing_key(self):
        return 'service.{}'.format(self.name)

Lukas Burgey's avatar
Lukas Burgey committed
371
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
372
        return self.name
Lukas Burgey's avatar
Lukas Burgey committed
373
374
375


class SSHPublicKey(models.Model):
Lukas Burgey's avatar
Lukas Burgey committed
376
377
378
379
380
381
    name = models.CharField(
        max_length=150,
    )
    key = models.TextField(
        max_length=1000
    )
Lukas Burgey's avatar
Lukas Burgey committed
382
    # hidden field at the user
383
    # TODO checks: if the user is null
Lukas Burgey's avatar
Lukas Burgey committed
384
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
385
386
        User,
        related_name='_ssh_keys',
387
388
        on_delete=models.SET_NULL,
        null=True,
Lukas Burgey's avatar
Lukas Burgey committed
389
    )
Lukas Burgey's avatar
Lukas Burgey committed
390

Lukas Burgey's avatar
Lukas Burgey committed
391
392
    # has the user triggered the deletion of this key
    deleted = models.BooleanField(
Lukas Burgey's avatar
Lukas Burgey committed
393
394
395
        default=False,
        editable=False,
    )
396

Lukas Burgey's avatar
Lukas Burgey committed
397
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
398
        return '[SSHPublicKey:{}] {}'.format(self, msg)
399

400
401
402
    # does not directly delete the key if the key is deployed or withdrawn
    # somewhere
    # the receiver 'delete_withdrawn_ssh_key' does the actual deletion
403
    def delete_key(self):
Lukas Burgey's avatar
Lukas Burgey committed
404
        if (not self.tasks.exists() and not self.deployments.exists()):
Lukas Burgey's avatar
Lukas Burgey committed
405
            LOGGER.info(self.msg('Direct deletion of key'))
406
407
408
            self.delete()
            return

Lukas Burgey's avatar
Lukas Burgey committed
409
        LOGGER.info(self.msg('Deletion of key started'))
410
411
412
        self.deleted = True
        self.save()

Lukas Burgey's avatar
Lukas Burgey committed
413
        # delete implies withdrawing the key from all clients
414
415
416
        for deployment in self.deployments.all():
            deployment.withdraw_key(self)

Lukas Burgey's avatar
Lukas Burgey committed
417
418
    # 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
419
        if (self.deleted and not self.tasks.exists()):
Lukas Burgey's avatar
Lukas Burgey committed
420
            LOGGER.info(self.msg(
Lukas Burgey's avatar
Lukas Burgey committed
421
                'All clients have withdrawn this key. Final deletion'))
Lukas Burgey's avatar
Lukas Burgey committed
422
423
424
            self.delete()
            return

Lukas Burgey's avatar
Lukas Burgey committed
425
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
426
427
        if self.deleted:
            return "DELETED: {}".format(self.name)
Lukas Burgey's avatar
Lukas Burgey committed
428
429
430
        return self.name


431
# Deployment describes the credential state per user as it is supposed to be
432
433
434
435
#
# (exception: if is_active=False the ssh_keys contain the keys to be deployed
# if the deployment is reactivated)
#
436
437
# DeploymentTask is what is sent to the clients via rabbitmq
# The DeploymentTaskItem track the acknowledgements from the clients
Lukas Burgey's avatar
Lukas Burgey committed
438
439
class Deployment(models.Model):
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
440
441
442
443
        User,
        related_name='deployments',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
444
    service = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
445
446
447
448
        Service,
        related_name='deployments',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
449
    ssh_keys = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
450
451
452
453
        SSHPublicKey,
        related_name='deployments',
        blank=True,
    )
454
    ssh_keys_to_withdraw = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
455
456
457
458
        SSHPublicKey,
        related_name='withdrawn_deployments',
        blank=True,
    )
459
    is_active = models.BooleanField(
Lukas Burgey's avatar
Lukas Burgey committed
460
461
        default=True,
    )
462

Lukas Burgey's avatar
Lukas Burgey committed
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483

    # 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,
        ).filter(
            service=service,
        )
        if query.exists():
            return query.first()

        deployment = cls(
            user=user,
            service=service,
        )
        deployment.save()
        return deployment


484
485
486
    @property
    def withdrawals(self):
        return self.tasks.filter(action='withdraw')
Lukas Burgey's avatar
Lukas Burgey committed
487

488
489
490
    @property
    def deploys(self):
        return self.tasks.filter(action='deploy')
Lukas Burgey's avatar
Lukas Burgey committed
491

492
493
    def __str__(self):
        return '{}:{}'.format(self.service, self.user)
494

Lukas Burgey's avatar
Lukas Burgey committed
495
    def msg(self, msg):
496
        return '[Deployment:{}] {}'.format(self, msg)
497

498
499
500
    # deploy credentials which were deployed prior to deactivation
    def activate(self):
        if self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
501
            LOGGER.error(self.msg('already active'))
502
503
            return

Lukas Burgey's avatar
Lukas Burgey committed
504
        LOGGER.debug(self.msg(str(self.ssh_keys.all())))
505
506
507
508
509
        for key in self.ssh_keys.all():
            self._deploy_key(key)

        self.is_active = True
        self.save()
Lukas Burgey's avatar
Lukas Burgey committed
510
        LOGGER.info(self.msg('activated'))
511
512
513
514

    # withdraw all credentials
    def deactivate(self):
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
515
            LOGGER.error(self.msg('already deactivated'))
516
517
518
            return

        self.is_active = False
519
        self.save()
520

521
522
523
        for key in self.ssh_keys.all():
            self._withdraw_key(key)

Lukas Burgey's avatar
Lukas Burgey committed
524
        LOGGER.info(self.msg('deactivated'))
525
526
527

    # only deploy the key
    def _deploy_key(self, key):
Lukas Burgey's avatar
Lukas Burgey committed
528
        task = DeploymentTask.construct_deployment_task(
Lukas Burgey's avatar
Lukas Burgey committed
529
530
531
            deployment=self,
            key=key,
        )
532
533
        # publish the task
        task.publish()
534

535
    def _withdraw_key(self, key):
Lukas Burgey's avatar
Lukas Burgey committed
536
        task = DeploymentTask.construct_withdrawal_task(
Lukas Burgey's avatar
Lukas Burgey committed
537
538
539
            deployment=self,
            key=key,
        )
540

541
542
        # publish the task
        task.publish()
Lukas Burgey's avatar
Lukas Burgey committed
543

544
545
546
    # 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
547
            LOGGER.error(self.msg('cannot deploy while deactivated'))
548
549
550
551
552
553
554
555
556
557
558
559
560
            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
    def withdraw_key(self, key):
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
561
            LOGGER.error(self.msg('cannot withdraw while deactivated'))
562
563
564
565
566
567
568
569
570
571
            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()

        self._withdraw_key(key)

Lukas Burgey's avatar
Lukas Burgey committed
572

573
574
class DeploymentTask(models.Model):
    ACTION_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
575
576
577
        ('deploy', 'deploy'),
        ('withdraw', 'withdraw'),
    )
578
    action = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
579
580
581
        max_length=10,
        choices=ACTION_CHOICES,
    )
582
    key = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
583
584
585
586
        SSHPublicKey,
        related_name='tasks',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
587
    deployment = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
588
589
590
591
        Deployment,
        related_name='tasks',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
592

Lukas Burgey's avatar
Lukas Burgey committed
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
    @classmethod
    def construct_deployment_task(cls, deployment, key):
        # delete outstanding tasks which are made obsolete by this task
        for withdrawal in deployment.withdrawals.filter(key=key):
            LOGGER.debug(withdrawal.msg('now obsolete'))
            withdrawal.delete()

        task = cls(
            action='deploy',
            deployment=deployment,
            key=key,
        )
        task.save()
        LOGGER.debug(task.msg('generated'))

        # generate task items
        for site in deployment.service.site.all():
            deploy = DeploymentTaskItem(
                task=task,
                site=site,
            )
            deploy.save()
            LOGGER.debug(deploy.msg('generated'))

        return task

    @classmethod
    def construct_withdrawal_task(cls, deployment, key):
        # delete outstanding tasks which are made obsolete by this task
        for withdrawal in deployment.deploys.filter(key=key):
            LOGGER.debug(withdrawal.msg('now obsolete'))
            withdrawal.delete()

        task = cls(
            action='withdraw',
            deployment=deployment,
            key=key,
        )
        task.save()
        LOGGER.debug(task.msg('generated'))

        # generate task items
        for site in deployment.service.site.all():
            deploy = DeploymentTaskItem(
                task=task,
                site=site,
            )
            deploy.save()
            LOGGER.debug(deploy.msg('generated'))

        return task

Lukas Burgey's avatar
Lukas Burgey committed
645
646
647
648
649
650
651
652
    @property
    def user(self):
        return self.deployment.user

    @property
    def service(self):
        return self.deployment.service

653
    def __str__(self):
654
        return "{}:{}:{} - {}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
655
656
657
658
            self.deployment.service,
            self.deployment.user,
            self.key,
            self.action,
659
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
660
        )
661

Lukas Burgey's avatar
Lukas Burgey committed
662
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
663
        return '[DeploymentTask:{}] {}'.format(self, msg)
664
665

    def publish(self):
666
        # FIXME mitigating circular dependencies here
667
668
669
        from .clientapi.serializers import DeploymentTaskSerializer
        msg = json.dumps(DeploymentTaskSerializer(self).data)

670
        RabbitMQInstance.load().publish_by_service(
Lukas Burgey's avatar
Lukas Burgey committed
671
672
673
            self.service,
            msg,
        )
674
675
676

    # the client acked the receipt and execution of the task for his site
    def item_finished(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
677
        item = self.task_items.get(site=site)
Lukas Burgey's avatar
Lukas Burgey committed
678
        LOGGER.debug(item.msg('done'))
Lukas Burgey's avatar
Lukas Burgey committed
679
        item.delete()
680
681
682
683
684
685

        if not self.task_items.exists():
            self.finished()

    # maintenance after all task items are done
    def finished(self):
Lukas Burgey's avatar
Lukas Burgey committed
686
        LOGGER.info(self.msg('done'))
Lukas Burgey's avatar
Lukas Burgey committed
687
        self.delete()
688
689
690
691
692
693
694
695

        # check if this was the final withdraw in a key deletion
        if self.action == 'withdraw':
            self.key.try_final_deletion()


class DeploymentTaskItem(models.Model):
    task = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
696
697
698
699
        DeploymentTask,
        related_name='task_items',
        on_delete=models.CASCADE,
    )
700
    site = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
701
702
703
704
        Site,
        related_name='task_items',
        on_delete=models.CASCADE,
    )
705

Lukas Burgey's avatar
Lukas Burgey committed
706
    def __str__(self):
707
        return "{}@{}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
708
709
            self.task,
            self.site,
710
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
711
        )
712

Lukas Burgey's avatar
Lukas Burgey committed
713
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
714
        return '[DeploymentTaskItem:{}] {}'.format(self, msg)
715

Lukas Burgey's avatar
Lukas Burgey committed
716

717
718
719
#
# RECEIVERS
#
Lukas Burgey's avatar
Lukas Burgey committed
720

721
722
723
724
725
@receiver(post_save, sender=User)
def deactivate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

726
    if not instance.is_active and instance.is_active_at_clients:
727
728
729
730
731
732
733
734
        instance.deactivate()


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

735
    if instance.is_active and not instance.is_active_at_clients:
736
        instance.activate()