models.py 26 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

Lukas Burgey's avatar
Lukas Burgey committed
23

24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 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
47
48
49
def exchanges_default():
    return []

50

Lukas Burgey's avatar
Lukas Burgey committed
51
52
# clients are registerred at rabbitmq, when they are assigned to a site
# (because we only then know what services they provide)
53
class RabbitMQInstance(SingletonModel):
Lukas Burgey's avatar
Lukas Burgey committed
54
    host = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
55
56
57
        max_length=150,
        default='localhost',
    )
Lukas Burgey's avatar
Lukas Burgey committed
58
59
60
61
    vhost = models.CharField(
        max_length=150,
        default='%2f',
    )
Lukas Burgey's avatar
Lukas Burgey committed
62
63
64
65
    exchanges = JSONField(
        default=exchanges_default,
        null=True,
        blank=True,
Lukas Burgey's avatar
Lukas Burgey committed
66
    )
Lukas Burgey's avatar
Lukas Burgey committed
67
    port = models.IntegerField(
Lukas Burgey's avatar
Lukas Burgey committed
68
        default=15672,
Lukas Burgey's avatar
Lukas Burgey committed
69
    )
Lukas Burgey's avatar
Lukas Burgey committed
70
    username = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
71
72
73
        max_length=150,
        default='guest',
    )
Lukas Burgey's avatar
Lukas Burgey committed
74
    password = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
75
76
77
        max_length=150,
        default='guest',
    )
Lukas Burgey's avatar
Lukas Burgey committed
78
79
80
81

    def __str__(self):
        return self.host

Lukas Burgey's avatar
Lukas Burgey committed
82
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
83
        return '[RabbitMQ:{}] {}'.format(self.host, msg)
Lukas Burgey's avatar
Lukas Burgey committed
84
85
86
87

    @property
    def auth(self):
        return HTTPBasicAuth(
Lukas Burgey's avatar
Lukas Burgey committed
88
            self.username,
Lukas Burgey's avatar
Lukas Burgey committed
89
            self.password,
Lukas Burgey's avatar
Lukas Burgey committed
90
        )
Lukas Burgey's avatar
Lukas Burgey committed
91

92
93
    def _init_exchanges(self, channel):
        channel.exchange_declare(
Lukas Burgey's avatar
Lukas Burgey committed
94
95
96
97
98
99
100
            exchange='deployments',
            durable=True,
            auto_delete=False,
            exchange_type='topic',
        )
        channel.exchange_declare(
            exchange='sites',
101
102
103
104
105
106
107
108
109
110
111
112
113
            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
114
        #LOGGER.debug('Opening new BlockingConnection')
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
        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
140
    @property
141
    def _channel(self):
Lukas Burgey's avatar
Lukas Burgey committed
142
143
144
145
146
147
148
149
        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
150
151
152

    def _publish(self, exchange, routing_key, body):
        channel = self._channel
153
        channel.basic_publish(
154
155
156
157
158
159
            exchange=exchange,
            routing_key=routing_key,
            body=body,
            properties=pika.BasicProperties(
                delivery_mode=1,
            ),
Lukas Burgey's avatar
Lukas Burgey committed
160
        )
161
        channel.close()
162

163
    # PUBLIC API
164
    def publish_by_service(self, service, msg):
165
        self._publish(
Lukas Burgey's avatar
Lukas Burgey committed
166
            'deployments',
167
168
169
            service.routing_key,
            msg,
        )
Lukas Burgey's avatar
Lukas Burgey committed
170

Lukas Burgey's avatar
Lukas Burgey committed
171
172
173
174
175
176
177
    def publish_by_site(self, site, msg):
        self._publish(
            'sites',
            site.name,
            msg,
        )

178
    def publish_to_webpage(self, user, msg):
179
180
        # noise
        # LOGGER.debug('Signalling webpage of user %s', user)
181
182
183
        self._publish(
            'update',
            str(user.id),
184
            json.dumps(msg),
185
        )
186

Lukas Burgey's avatar
Lukas Burgey committed
187

188
189
190
191
def user_info_default():
    return {}


Lukas Burgey's avatar
Lukas Burgey committed
192
class User(AbstractUser):
193
    TYPE_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
194
195
196
197
        ('apiclient', 'API-Client'),
        ('oidcuser', 'OIDC User'),
        ('admin', 'Admin'),
    )
198
    user_type = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
199
200
        max_length=20,
        choices=TYPE_CHOICES,
Lukas Burgey's avatar
Lukas Burgey committed
201
        default='apiclient',
Lukas Burgey's avatar
Lukas Burgey committed
202
    )
203
204
205
206
    sub = models.CharField(
        max_length=150,
        blank=True,
        null=True,
207
        editable=False,
208
209
210
211
212
213
214
215
216
217
218
219
    )
    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
220
221
222
223
224
    # the idp which authenticated the user
    idp = models.ForeignKey(
        OIDCConfig,
        related_name='users',
        on_delete=models.CASCADE,
225
226
227
        blank=True,
        null=True,
        editable=False,
Lukas Burgey's avatar
Lukas Burgey committed
228
    )
229
230
231
232
    userinfo = JSONField(
        default=user_info_default,
        null=True,
        blank=True,
233
        editable=False,
234
    )
Lukas Burgey's avatar
Lukas Burgey committed
235

Lukas Burgey's avatar
Lukas Burgey committed
236
237
238
239
    # 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
240
        if 'sub' not in userinfo:
Lukas Burgey's avatar
Lukas Burgey committed
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
            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):
259
        LOGGER.debug('Constructing User from:\n%s', userinfo)
Lukas Burgey's avatar
Lukas Burgey committed
260
261
262
263
264
265

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

        if 'email' not in userinfo:
266
            username = sub
Lukas Burgey's avatar
Lukas Burgey committed
267
268
        else:
            username = userinfo['email']
269
            email = userinfo['email']
Lukas Burgey's avatar
Lukas Burgey committed
270
271
272
273
274

        user = cls(
            user_type='oidcuser',
            username=username,
            sub=sub,
275
            email=email,
Lukas Burgey's avatar
Lukas Burgey committed
276
277
278
279
            idp=idp,
            userinfo=userinfo,
        )
        user.save()
Lukas Burgey's avatar
Lukas Burgey committed
280
281
282
283
284
285

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

Lukas Burgey's avatar
Lukas Burgey committed
286
287
288
289
290
291
292
293
294
295
296
297
        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
298
    # we hide deleted keys here
299
    # the full list of ssh keys is self._ssh_keys
Lukas Burgey's avatar
Lukas Burgey committed
300
301
302
303
    @property
    def ssh_keys(self):
        return self._ssh_keys.filter(deleted=False)

304
305
306
307
    @property
    def is_active_at_clients(self):
        return self._is_active

308
309
310
    def __str__(self):
        if self.user_type == 'admin':
            return 'ADMIN {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
311
        elif self.user_type == 'oidcuser':
312
313
314
            if not self.is_active:
                return 'DEACTIVATED USER {}'.format(self.username)
            return 'USER {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
315
        elif self.user_type == 'apiclient':
316
317
318
319
320
            try:
                return 'APICLIENT {}@{}'.format(self.username, self.site)
            except:
                return 'APICLIENT {}'.format(self.username)

Lukas Burgey's avatar
Lukas Burgey committed
321
322
        else:
            raise Exception()
Lukas Burgey's avatar
Lukas Burgey committed
323

Lukas Burgey's avatar
Lukas Burgey committed
324
    def msg(self, msg):
325
326
327
328
329
330
331
        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
332
            # FIXME: deleting the user brings problems:
333
334
            # 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
335
            LOGGER.info(self.msg('Deleting'))
336
337
338
            self.delete()

    def activate(self):
339
        if self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
340
            LOGGER.error(self.msg('already activated'))
341
342
            return

Lukas Burgey's avatar
Lukas Burgey committed
343
344
345
346
        self.is_active = True
        self._is_active = True
        self.save()
        LOGGER.info(self.msg('activated'))
347

Lukas Burgey's avatar
Lukas Burgey committed
348
349
        # oidcuser: deploy the according credentials
        if self.user_type == 'oidcuser':
350
351
352
353
            for dep in self.deployments.all():
                dep.activate()

    def deactivate(self):
354
        if not self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
355
            LOGGER.error(self.msg('already deactivated'))
356
357
            return

Lukas Burgey's avatar
Lukas Burgey committed
358
359
360
361
362
363
        self.is_active = False
        self._is_active = False
        self.save()
        LOGGER.info(self.msg('deactivated'))

        # oidcuser: withdraw all credentials
364
365
366
367
368
        if self.user_type == 'oidcuser':

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

Lukas Burgey's avatar
Lukas Burgey committed
369

Lukas Burgey's avatar
Lukas Burgey committed
370
371
372
373
374
375
376
377
378
379
380
381
# 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
382
class Site(models.Model):
383
    client = models.OneToOneField(
Lukas Burgey's avatar
Lukas Burgey committed
384
385
        User,
        related_name='site',
386
387
388
389
390
391
392
393
394
395
396
        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
397
    )
Lukas Burgey's avatar
Lukas Burgey committed
398
399
400
401

    def __str__(self):
        return self.name

402
403
404
    # tasks which are still to be executed on this site
    @property
    def tasks(self):
405
406
407
        task_items = self.task_items.filter(state='pending')\
            | self.task_items.filter(state='failed')\
            | self.task_items.filter(state='answered')
408
409
        return [item.task
                for item
410
                in task_items]
Lukas Burgey's avatar
Lukas Burgey committed
411

Lukas Burgey's avatar
Lukas Burgey committed
412
413
414
415

class Service(models.Model):
    name = models.CharField(max_length=150, unique=True)
    description = models.TextField(max_length=300, blank=True)
416
    site = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
417
418
        Site,
        related_name='services')
Lukas Burgey's avatar
Lukas Burgey committed
419
    groups = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
420
421
422
        Group,
        related_name='services',
        blank=True)
Lukas Burgey's avatar
Lukas Burgey committed
423

424
425
426
427
    @property
    def routing_key(self):
        return 'service.{}'.format(self.name)

Lukas Burgey's avatar
Lukas Burgey committed
428
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
429
        return self.name
Lukas Burgey's avatar
Lukas Burgey committed
430
431
432


class SSHPublicKey(models.Model):
Lukas Burgey's avatar
Lukas Burgey committed
433
434
435
436
437
438
    name = models.CharField(
        max_length=150,
    )
    key = models.TextField(
        max_length=1000
    )
Lukas Burgey's avatar
Lukas Burgey committed
439
    # hidden field at the user
440
    # TODO checks: if the user is null
Lukas Burgey's avatar
Lukas Burgey committed
441
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
442
443
        User,
        related_name='_ssh_keys',
444
445
        on_delete=models.SET_NULL,
        null=True,
Lukas Burgey's avatar
Lukas Burgey committed
446
    )
Lukas Burgey's avatar
Lukas Burgey committed
447

Lukas Burgey's avatar
Lukas Burgey committed
448
449
    # has the user triggered the deletion of this key
    deleted = models.BooleanField(
Lukas Burgey's avatar
Lukas Burgey committed
450
451
452
        default=False,
        editable=False,
    )
453

Lukas Burgey's avatar
Lukas Burgey committed
454
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
455
        return '[SSHPublicKey:{}] {}'.format(self, msg)
456

457
458
459
    # does not directly delete the key if the key is deployed or withdrawn
    # somewhere
    # the receiver 'delete_withdrawn_ssh_key' does the actual deletion
460
    def delete_key(self):
Lukas Burgey's avatar
Lukas Burgey committed
461
        if (not self.tasks.exists() and not self.deployments.exists()):
Lukas Burgey's avatar
Lukas Burgey committed
462
            LOGGER.info(self.msg('Direct deletion of key'))
463
464
465
            self.delete()
            return

Lukas Burgey's avatar
Lukas Burgey committed
466
        LOGGER.info(self.msg('Deletion of key started'))
467
468
469
        self.deleted = True
        self.save()

Lukas Burgey's avatar
Lukas Burgey committed
470
        # delete implies withdrawing the key from all clients
471
472
473
        for deployment in self.deployments.all():
            deployment.withdraw_key(self)

Lukas Burgey's avatar
Lukas Burgey committed
474
475
    # 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
476
        if (self.deleted and not self.tasks.exists()):
Lukas Burgey's avatar
Lukas Burgey committed
477
            LOGGER.info(self.msg(
Lukas Burgey's avatar
Lukas Burgey committed
478
                'All clients have withdrawn this key. Final deletion'))
Lukas Burgey's avatar
Lukas Burgey committed
479
480
481
            self.delete()
            return

Lukas Burgey's avatar
Lukas Burgey committed
482
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
483
484
        if self.deleted:
            return "DELETED: {}".format(self.name)
Lukas Burgey's avatar
Lukas Burgey committed
485
486
487
        return self.name


488
# Deployment describes the credential state per user (and site) as it is supposed to be
489
490
491
492
#
# (exception: if is_active=False the ssh_keys contain the keys to be deployed
# if the deployment is reactivated)
#
493
494
# 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
495
496
class Deployment(models.Model):
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
497
498
499
500
        User,
        related_name='deployments',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
501
    service = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
502
503
504
505
        Service,
        related_name='deployments',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
506
    ssh_keys = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
507
508
509
510
        SSHPublicKey,
        related_name='deployments',
        blank=True,
    )
511
    ssh_keys_to_withdraw = models.ManyToManyField(
Lukas Burgey's avatar
Lukas Burgey committed
512
513
514
515
        SSHPublicKey,
        related_name='withdrawn_deployments',
        blank=True,
    )
516
    is_active = models.BooleanField(
Lukas Burgey's avatar
Lukas Burgey committed
517
518
        default=True,
    )
519

Lukas Burgey's avatar
Lukas Burgey committed
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539

    # 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()
        return deployment


540
541
542
    @property
    def withdrawals(self):
        return self.tasks.filter(action='withdraw')
Lukas Burgey's avatar
Lukas Burgey committed
543

544
545
546
    @property
    def deploys(self):
        return self.tasks.filter(action='deploy')
Lukas Burgey's avatar
Lukas Burgey committed
547

548
549
    def __str__(self):
        return '{}:{}'.format(self.service, self.user)
550

Lukas Burgey's avatar
Lukas Burgey committed
551
    def msg(self, msg):
552
        return '[Deployment:{}] {}'.format(self, msg)
553

554
555
556
    # deploy credentials which were deployed prior to deactivation
    def activate(self):
        if self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
557
            LOGGER.error(self.msg('already active'))
558
559
            return

Lukas Burgey's avatar
Lukas Burgey committed
560
        LOGGER.debug(self.msg(str(self.ssh_keys.all())))
561
562
563
564
565
        for key in self.ssh_keys.all():
            self._deploy_key(key)

        self.is_active = True
        self.save()
Lukas Burgey's avatar
Lukas Burgey committed
566
        LOGGER.info(self.msg('activated'))
567
568
569
570

    # withdraw all credentials
    def deactivate(self):
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
571
            LOGGER.error(self.msg('already deactivated'))
572
573
574
            return

        self.is_active = False
575
        self.save()
576

577
578
579
        for key in self.ssh_keys.all():
            self._withdraw_key(key)

Lukas Burgey's avatar
Lukas Burgey committed
580
        LOGGER.info(self.msg('deactivated'))
581
582
583

    # only deploy the key
    def _deploy_key(self, key):
Lukas Burgey's avatar
Lukas Burgey committed
584
        task = DeploymentTask.construct_deployment_task(
Lukas Burgey's avatar
Lukas Burgey committed
585
586
587
            deployment=self,
            key=key,
        )
588
589
        # publish the task
        task.publish()
590

591
    def _withdraw_key(self, key):
Lukas Burgey's avatar
Lukas Burgey committed
592
        task = DeploymentTask.construct_withdrawal_task(
Lukas Burgey's avatar
Lukas Burgey committed
593
594
595
            deployment=self,
            key=key,
        )
596

597
598
        # publish the task
        task.publish()
Lukas Burgey's avatar
Lukas Burgey committed
599

600
601
602
    # 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
603
            LOGGER.error(self.msg('cannot deploy while deactivated'))
604
605
606
607
608
609
610
611
612
613
614
615
616
            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
617
            LOGGER.error(self.msg('cannot withdraw while deactivated'))
618
619
620
621
622
623
624
625
626
627
            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
628

629
630
631
632
633
634
635
636
637
def invert_action(action):
    if action == 'deploy':
        return 'withdraw'
    elif action == 'withdraw':
        return 'deploy'


# DeploymentTask: knows:
# user, service, key, action
638
639
class DeploymentTask(models.Model):
    ACTION_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
640
641
642
        ('deploy', 'deploy'),
        ('withdraw', 'withdraw'),
    )
643
    action = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
644
645
646
        max_length=10,
        choices=ACTION_CHOICES,
    )
647
    key = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
648
649
650
651
        SSHPublicKey,
        related_name='tasks',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
652
    deployment = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
653
654
655
656
        Deployment,
        related_name='tasks',
        on_delete=models.CASCADE,
    )
657
658
659
660
661
    user = models.ForeignKey(
        User,
        related_name='deployment_tasks',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
662

663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
    # the inverse action of this task is requirred
    # so we invert the task and manage its task_items accordingly
    def invert_task(self):
        LOGGER.debug(self.msg('inverting'))

        previous_action = self.action
        self.action = invert_action(previous_action)
        self.save()

        pending_sites = [task.site for task in self.task_items.all()]
        self.chancel_items()

        # sites which already executed the task
        # we have to send them an order to rollback the changes
        for site in self.deployment.service.site.all():
            if site not in pending_sites:
                deploy = DeploymentTaskItem(
                    task=self,
                    site=site,
                    user=self.deployment.user
                )
                deploy.save()
                LOGGER.debug(deploy.msg('pending'))

Lukas Burgey's avatar
Lukas Burgey committed
687
688
    @classmethod
    def construct_deployment_task(cls, deployment, key):
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
        # does a task exist for this key?
        query = deployment.tasks.filter(key=key)
        if query.exists():
            if len(query) > 1:
                raise Exception('Unexpected query result')

            task = query.first()
            if task.action == 'deploy':
                raise Exception('Constructing deployment task when one already exists')

            task.invert_task()
            return task

        else:
            #create new task
            task = cls(
                action='deploy',
                deployment=deployment,
                key=key,
                user=deployment.user,
Lukas Burgey's avatar
Lukas Burgey committed
709
            )
710
711
712
713
714
715
716
717
718
719
720
721
            task.save()
            LOGGER.debug(task.msg('pending'))

            # generate task items
            for site in deployment.service.site.all():
                deploy = DeploymentTaskItem(
                    task=task,
                    site=site,
                    user=deployment.user
                )
                deploy.save()
                LOGGER.debug(deploy.msg('pending'))
Lukas Burgey's avatar
Lukas Burgey committed
722

723
            return task
Lukas Burgey's avatar
Lukas Burgey committed
724
725
726

    @classmethod
    def construct_withdrawal_task(cls, deployment, key):
727
728
729
730
731
        # does a task exist for this key?
        query = deployment.tasks.filter(key=key)
        if query.exists():
            if len(query) > 1:
                raise Exception('Unexpected query result')
Lukas Burgey's avatar
Lukas Burgey committed
732

733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
            task = query.first()
            if task.action == 'withdraw':
                raise Exception('Constructing deployment task when one already exists')

            task.invert_task()
            return task

        else:
            # create a new task
            task = cls(
                action='withdraw',
                deployment=deployment,
                key=key,
                user=deployment.user,
            )
            task.save()
            LOGGER.debug(task.msg('pending'))

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

            return task

    def chancel_items(self):
        for item in self.task_items.all():
            item.chancel()

    def chancel(self):
        self.chancel_items()
        LOGGER.debug(self.msg("chanceled"))
        self.delete()
Lukas Burgey's avatar
Lukas Burgey committed
771

Lukas Burgey's avatar
Lukas Burgey committed
772
773
774
775
    @property
    def service(self):
        return self.deployment.service

776
    def __str__(self):
777
        return "{}:{}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
778
779
            self.deployment.service,
            self.action,
780
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
781
        )
782

Lukas Burgey's avatar
Lukas Burgey committed
783
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
784
        return '[DeploymentTask:{}] {}'.format(self, msg)
785
786

    def publish(self):
Lukas Burgey's avatar
Lukas Burgey committed
787
        # mitigating circular dependencies here
788
789
790
        from .clientapi.serializers import DeploymentTaskSerializer
        msg = json.dumps(DeploymentTaskSerializer(self).data)

791
        RabbitMQInstance.load().publish_by_service(
Lukas Burgey's avatar
Lukas Burgey committed
792
793
794
            self.service,
            msg,
        )
795

796
797
798
799
800
801
802
803
804
805
806
    # update the state of the remote webpage
    def send_state_update(self):
        from .frontend.views import user_state_dict
        content = {
            'user_state': user_state_dict(self.user),
        }
        RabbitMQInstance.load().publish_to_webpage(
            self.user,
            content,
        )

807
    def try_finished(self):
808
        if not self.task_items.exists():
809
            # finished sends its own message
Lukas Burgey's avatar
Lukas Burgey committed
810
            self._finished()
811
812

    # maintenance after all task items are done
Lukas Burgey's avatar
Lukas Burgey committed
813
    def _finished(self):
Lukas Burgey's avatar
Lukas Burgey committed
814
        LOGGER.info(self.msg('done'))
815

Lukas Burgey's avatar
Lukas Burgey committed
816
        self.delete()
817
818
819
820
821

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

Lukas Burgey's avatar
Lukas Burgey committed
822
823
824
825
826
        message = 'Completed: {} {} @ {}'.format(
            self.action,
            self.key,
            self.service,
        )
827
828
829
830
831
832
833
834
835
836
837

        from .frontend.views import user_state_dict
        content = {
            'user_state': user_state_dict(self.user),
            'message': message,
        }
        RabbitMQInstance.load().publish_to_webpage(
            self.user,
            content,
        )

838

839
840
841
842
843
def questionnaire_default():
    return {}

# DeploymentTaskItem: knows:
# user, service, key, action, _and_ site
844
845
class DeploymentTaskItem(models.Model):
    task = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
846
847
848
849
        DeploymentTask,
        related_name='task_items',
        on_delete=models.CASCADE,
    )
850
    site = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
851
852
853
854
        Site,
        related_name='task_items',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
855
856
857
858
859
    user = models.ForeignKey(
        User,
        related_name='deployment_task_items',
        on_delete=models.CASCADE,
    )
860
861
862
863
864
865
    STATE_CHOICES = (
        ('pending', 'Pending'),
        ('done', 'Done'),
        ('chanceled', 'Chanceled'),
        ('failed', 'Failed'),
        ('rejected', 'Rejected'),
866
        ('answered', 'Answered'),
867
868
869
870
871
872
    )
    state = models.CharField(
        max_length=20,
        choices=STATE_CHOICES,
        default='pending',
    )
Lukas Burgey's avatar
Lukas Burgey committed
873

874
875
876
877
878
879
    questionnaire = JSONField(
        default=questionnaire_default,
        null=True,
        blank=True,
    )

Lukas Burgey's avatar
Lukas Burgey committed
880
881
882
883
884
885
886
887
888
889
890
    @property
    def service(self):
        return self.task.service

    @property
    def key(self):
        return self.task.key

    @property
    def action(self):
        return self.deployment.key
891

892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
    # the client acked the receipt and execution of the task for his site
    def success(self):
        task = self.task

        LOGGER.debug(self.msg('success'))
        self.delete()

        task.send_state_update()
        task.try_finished()

    # the user changed the deployment
    # chancel (delete) this task item
    def chancel(self):
        LOGGER.debug(self.msg('chanceled'))
        self.delete()

        # no update on chancel
        # the next task will send an update
        #task.send_state_update()

    # the client failed to execute the item
    # the client can try again later
    # we signal the user about the failure
    def failed(self, site):
        LOGGER.debug(self.msg('failed'))
        self.state = 'failed'
        self.save()

        self.task.send_state_update()

    # the client failed to execute the item
    # the client needs additional information from the user to try again
    # we have to ask the user for data
    def rejected(self, questionnaire=None):
        LOGGER.debug(self.msg('rejected'))
        self.state = 'rejected'
        self.questionnaire = questionnaire
        self.save()

        self.task.send_state_update()

    def questionnaire_answered(self, answers=None):
        LOGGER.debug('%s %s', self.msg('answers'), answers)
        self.state = 'answered'
        self.questionnaire = answers
        self.save()

Lukas Burgey's avatar
Lukas Burgey committed
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
        # publish the task item
        self.publish()

    # only used when we got a questionnaire_answered
    def publish(self):
        # mitigating circular dependencies here
        from .clientapi.serializers import DeploymentTaskSerializer
        data = DeploymentTaskSerializer(self.task).data
        data['questionnaire'] = self.questionnaire

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


Lukas Burgey's avatar
Lukas Burgey committed
955
    def __str__(self):
956
        return "{}@{}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
957
958
            self.task,
            self.site,
959
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
960
        )
961

Lukas Burgey's avatar
Lukas Burgey committed
962
    def msg(self, msg):
963
964
        return '[Depl. TaskItem:{}] {}'.format(self, msg)

965

Lukas Burgey's avatar
Lukas Burgey committed
966

967
968
969
#
# RECEIVERS
#
Lukas Burgey's avatar
Lukas Burgey committed
970

971
972
973
974
975
@receiver(post_save, sender=User)
def deactivate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

976
    if not instance.is_active and instance.is_active_at_clients:
977
978
979
980
981
982
983
984
        instance.deactivate()


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

985
    if instance.is_active and not instance.is_active_at_clients:
986
        instance.activate()