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

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:
Lukas Burgey's avatar
Lukas Burgey committed
333
            # the deletion cascades down to DeploymentState and DeploymentStateItem
334
            # 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

Lukas Burgey's avatar
Lukas Burgey committed
402
    # states which are still to be executed on this site
403
    @property
Lukas Burgey's avatar
Lukas Burgey committed
404
405
406
407
408
    def states(self):
        state_items = self.state_items.filter(state='pending')\
            | self.state_items.filter(state='failed')\
            | self.state_items.filter(state='answered')
        return [item.state
409
                for item
Lukas Burgey's avatar
Lukas Burgey committed
410
                in state_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.states.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.states.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)
#
Lukas Burgey's avatar
Lukas Burgey committed
493
494
# 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
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
    @property
    def withdrawals(self):
Lukas Burgey's avatar
Lukas Burgey committed
542
        return self.states.filter(action='withdraw')
Lukas Burgey's avatar
Lukas Burgey committed
543

544
545
    @property
    def deploys(self):
Lukas Burgey's avatar
Lukas Burgey committed
546
        return self.states.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
        state = DeploymentState.construct_deployment_state(
Lukas Burgey's avatar
Lukas Burgey committed
585
586
587
            deployment=self,
            key=key,
        )
Lukas Burgey's avatar
Lukas Burgey committed
588
589
        # publish the state
        state.publish()
590

591
    def _withdraw_key(self, key):
Lukas Burgey's avatar
Lukas Burgey committed
592
        state = DeploymentState.construct_withdrawal_state(
Lukas Burgey's avatar
Lukas Burgey committed
593
594
595
            deployment=self,
            key=key,
        )
596

Lukas Burgey's avatar
Lukas Burgey committed
597
598
        # publish the state
        state.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
def invert_action(action):
    if action == 'deploy':
        return 'withdraw'
    elif action == 'withdraw':
        return 'deploy'


Lukas Burgey's avatar
Lukas Burgey committed
636
# DeploymentState: knows:
637
# user, service, key, action
Lukas Burgey's avatar
Lukas Burgey committed
638
class DeploymentState(models.Model):
639
    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
        SSHPublicKey,
Lukas Burgey's avatar
Lukas Burgey committed
649
        related_name='states',
Lukas Burgey's avatar
Lukas Burgey committed
650
651
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
652
    deployment = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
653
        Deployment,
Lukas Burgey's avatar
Lukas Burgey committed
654
        related_name='states',
Lukas Burgey's avatar
Lukas Burgey committed
655
656
        on_delete=models.CASCADE,
    )
657
658
    user = models.ForeignKey(
        User,
Lukas Burgey's avatar
Lukas Burgey committed
659
        related_name='deployment_states',
660
661
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
662

Lukas Burgey's avatar
Lukas Burgey committed
663
664
665
    # the inverse action of this state is requirred
    # so we invert the state and manage its state_items accordingly
    def invert_state(self):
666
667
668
669
670
671
        LOGGER.debug(self.msg('inverting'))

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

Lukas Burgey's avatar
Lukas Burgey committed
672
        pending_sites = [state.site for state in self.state_items.all()]
673
674
        self.chancel_items()

Lukas Burgey's avatar
Lukas Burgey committed
675
        # sites which already executed the state
676
677
678
        # 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:
Lukas Burgey's avatar
Lukas Burgey committed
679
680
                deploy = DeploymentStateItem(
                    state=self,
681
682
683
684
685
686
                    site=site,
                    user=self.deployment.user
                )
                deploy.save()
                LOGGER.debug(deploy.msg('pending'))

Lukas Burgey's avatar
Lukas Burgey committed
687
    @classmethod
Lukas Burgey's avatar
Lukas Burgey committed
688
689
690
    def construct_deployment_state(cls, deployment, key):
        # does a state exist for this key?
        query = deployment.states.filter(key=key)
691
692
693
694
        if query.exists():
            if len(query) > 1:
                raise Exception('Unexpected query result')

Lukas Burgey's avatar
Lukas Burgey committed
695
696
697
            state = query.first()
            if state.action == 'deploy':
                raise Exception('Constructing deployment state when one already exists')
698

Lukas Burgey's avatar
Lukas Burgey committed
699
700
            state.invert_state()
            return state
701
702

        else:
Lukas Burgey's avatar
Lukas Burgey committed
703
704
            #create new state
            state = cls(
705
706
707
708
                action='deploy',
                deployment=deployment,
                key=key,
                user=deployment.user,
Lukas Burgey's avatar
Lukas Burgey committed
709
            )
Lukas Burgey's avatar
Lukas Burgey committed
710
711
            state.save()
            LOGGER.debug(state.msg('pending'))
712

Lukas Burgey's avatar
Lukas Burgey committed
713
            # generate state items
714
            for site in deployment.service.site.all():
Lukas Burgey's avatar
Lukas Burgey committed
715
716
                deploy = DeploymentStateItem(
                    state=state,
717
718
719
720
721
                    site=site,
                    user=deployment.user
                )
                deploy.save()
                LOGGER.debug(deploy.msg('pending'))
Lukas Burgey's avatar
Lukas Burgey committed
722

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

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

Lukas Burgey's avatar
Lukas Burgey committed
733
734
735
            state = query.first()
            if state.action == 'withdraw':
                raise Exception('Constructing deployment state when one already exists')
736

Lukas Burgey's avatar
Lukas Burgey committed
737
738
            state.invert_state()
            return state
739
740

        else:
Lukas Burgey's avatar
Lukas Burgey committed
741
742
            # create a new state
            state = cls(
743
744
745
746
747
                action='withdraw',
                deployment=deployment,
                key=key,
                user=deployment.user,
            )
Lukas Burgey's avatar
Lukas Burgey committed
748
749
            state.save()
            LOGGER.debug(state.msg('pending'))
750

Lukas Burgey's avatar
Lukas Burgey committed
751
            # generate state items
752
            for site in deployment.service.site.all():
Lukas Burgey's avatar
Lukas Burgey committed
753
754
                deploy = DeploymentStateItem(
                    state=state,
755
756
757
758
759
760
                    site=site,
                    user=deployment.user
                )
                deploy.save()
                LOGGER.debug(deploy.msg('pending'))

Lukas Burgey's avatar
Lukas Burgey committed
761
            return state
762
763

    def chancel_items(self):
Lukas Burgey's avatar
Lukas Burgey committed
764
        for item in self.state_items.all():
765
766
767
768
769
770
            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 '[DState:{}] {}'.format(self, msg)
785
786

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

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):
Lukas Burgey's avatar
Lukas Burgey committed
808
        if not self.state_items.exists():
809
            # finished sends its own message
Lukas Burgey's avatar
Lukas Burgey committed
810
            self._finished()
811

Lukas Burgey's avatar
Lukas Burgey committed
812
    # maintenance after all state 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
def questionnaire_default():
    return {}

842
843
844
def credential_default():
    return {}

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

877
878
879
880
881
882
    questionnaire = JSONField(
        default=questionnaire_default,
        null=True,
        blank=True,
    )

883
884
885
886
887
888
    credentials = JSONField(
        default=credential_default,
        null=True,
        blank=True,
    )

Lukas Burgey's avatar
Lukas Burgey committed
889
890
    @property
    def service(self):
Lukas Burgey's avatar
Lukas Burgey committed
891
        return self.state.service
Lukas Burgey's avatar
Lukas Burgey committed
892
893
894

    @property
    def key(self):
Lukas Burgey's avatar
Lukas Burgey committed
895
        return self.state.key
Lukas Burgey's avatar
Lukas Burgey committed
896
897
898
899

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

Lukas Burgey's avatar
Lukas Burgey committed
901
    # the client acked the receipt and execution of the state for his site
902
    def success(self, credentials=None):
Lukas Burgey's avatar
Lukas Burgey committed
903
        state = self.state
904
905
        self.credentials = credentials
        self.save()
906
907

        LOGGER.debug(self.msg('success'))
Lukas Burgey's avatar
Lukas Burgey committed
908
        # TODO test: does not deleting the state item work?
909
        # self.delete()
910

Lukas Burgey's avatar
Lukas Burgey committed
911
        state.send_state_update()
912

Lukas Burgey's avatar
Lukas Burgey committed
913
914
        # TODO test: does not deleting the state  work?
        #state.try_finished()
915
916

    # the user changed the deployment
Lukas Burgey's avatar
Lukas Burgey committed
917
    # chancel (delete) this state item
918
919
920
921
922
    def chancel(self):
        LOGGER.debug(self.msg('chanceled'))
        self.delete()

        # no update on chancel
Lukas Burgey's avatar
Lukas Burgey committed
923
924
        # the next state will send an update
        #state.send_state_update()
925
926
927
928
929
930
931
932
933

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

Lukas Burgey's avatar
Lukas Burgey committed
934
        self.state.send_state_update()
935
936
937
938
939
940
941
942
943
944

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

Lukas Burgey's avatar
Lukas Burgey committed
945
        self.state.send_state_update()
946
947
948
949
950
951
952

    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
953
        # publish the state item
Lukas Burgey's avatar
Lukas Burgey committed
954
955
956
957
958
        self.publish()

    # only used when we got a questionnaire_answered
    def publish(self):
        # mitigating circular dependencies here
Lukas Burgey's avatar
Lukas Burgey committed
959
960
        from .clientapi.serializers import DeploymentStateSerializer
        data = DeploymentStateSerializer(self.state).data
Lukas Burgey's avatar
Lukas Burgey committed
961
962
963
964
965
966
967
968
        data['questionnaire'] = self.questionnaire

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


Lukas Burgey's avatar
Lukas Burgey committed
969
    def __str__(self):
970
        return "{}@{}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
971
            self.state,
Lukas Burgey's avatar
Lukas Burgey committed
972
            self.site,
973
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
974
        )
975

Lukas Burgey's avatar
Lukas Burgey committed
976
    def msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
977
        return '[DSItem:{}] {}'.format(self, msg)
978

979
980
981
982

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

984
985
986
987
988
@receiver(post_save, sender=User)
def deactivate_user(sender, instance=None, created=False, **kwargs):
    if created:
        return

989
    if not instance.is_active and instance.is_active_at_clients:
990
991
992
993
994
995
996
997
        instance.deactivate()


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

998
    if instance.is_active and not instance.is_active_at_clients:
999
        instance.activate()