models.py 21.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
8
from pika.exceptions import ConnectionClosed
Lukas Burgey's avatar
Lukas Burgey committed
9
10
import requests
from requests.auth import HTTPBasicAuth
Lukas Burgey's avatar
Lukas Burgey committed
11
from django.conf import settings
Lukas Burgey's avatar
Lukas Burgey committed
12
from django.contrib.auth.models import AbstractUser, Group
13
from django.core.cache import cache
Lukas Burgey's avatar
Lukas Burgey committed
14
from django.db import models
15
from django.db.models.signals import post_save, pre_delete
Lukas Burgey's avatar
Lukas Burgey committed
16
from django.dispatch import receiver
17
from django_mysql.models import JSONField
Lukas Burgey's avatar
Lukas Burgey committed
18
from rest_framework.authtoken.models import Token
Lukas Burgey's avatar
Lukas Burgey committed
19
from .auth.v1.models import OIDCConfig
Lukas Burgey's avatar
Lukas Burgey committed
20

Lukas Burgey's avatar
Lukas Burgey committed
21
LOGGER = logging.getLogger(__name__)
22
RECONNECT_TIMEOUT = 3
23

Lukas Burgey's avatar
Lukas Burgey committed
24

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

    def __str__(self):
        return self.host

Lukas Burgey's avatar
Lukas Burgey committed
86
    def _msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
87
        return '[RabbitMQ:{}] {}'.format(self.host, msg)
Lukas Burgey's avatar
Lukas Burgey committed
88
89
90
91

    @property
    def auth(self):
        return HTTPBasicAuth(
Lukas Burgey's avatar
Lukas Burgey committed
92
            self.username,
Lukas Burgey's avatar
Lukas Burgey committed
93
            self.password,
Lukas Burgey's avatar
Lukas Burgey committed
94
        )
Lukas Burgey's avatar
Lukas Burgey committed
95
96

    @property
Lukas Burgey's avatar
Lukas Burgey committed
97
98
99
100
101
    def _connection_parameters(self):
        return pika.ConnectionParameters(
            host=self.host,
            ssl=True,
        )
102
103
104

    @property
    def connection(self):
105
        LOGGER.debug(self._msg('connection opened'))
Lukas Burgey's avatar
Lukas Burgey committed
106
107
108
        return pika.BlockingConnection(
            self._connection_parameters,
        )
109
110
111

    @property
    def channel(self):
Lukas Burgey's avatar
Lukas Burgey committed
112
113
114
115
116
117
118
119
        rabbitmq_channel = self.connection.channel()
        rabbitmq_channel.exchange_declare(
            exchange=self.exchange,
            durable=True,
            auto_delete=False,
            exchange_type='topic',
        )
        rabbitmq_channel.confirm_delivery()
120
        LOGGER.debug(self._msg('channel opened'))
Lukas Burgey's avatar
Lukas Burgey committed
121
122
123
124
125

        return rabbitmq_channel

    def _get_api_uri(self, path):
        return 'http://{}:{}/{}/{}'.format(
Lukas Burgey's avatar
Lukas Burgey committed
126
127
128
            self.host,
            self.port,
            self.path,
Lukas Burgey's avatar
Lukas Burgey committed
129
            path,
Lukas Burgey's avatar
Lukas Burgey committed
130
131
        )

Lukas Burgey's avatar
Lukas Burgey committed
132
    def _rest_get(self, api_path):
Lukas Burgey's avatar
Lukas Burgey committed
133
        req = requests.get(
Lukas Burgey's avatar
Lukas Burgey committed
134
            self._get_api_uri(api_path),
Lukas Burgey's avatar
Lukas Burgey committed
135
            auth=self.auth)
Lukas Burgey's avatar
Lukas Burgey committed
136
137
        req.raise_for_status()
        return req.json()
Lukas Burgey's avatar
Lukas Burgey committed
138
139
140

    # send a rest call with path and data to the rest interface of
    # the rabbitmq instance
Lukas Burgey's avatar
Lukas Burgey committed
141
    def _rest_put(self, api_path, data):
Lukas Burgey's avatar
Lukas Burgey committed
142
        req = requests.put(
Lukas Burgey's avatar
Lukas Burgey committed
143
            self._get_api_uri(api_path),
Lukas Burgey's avatar
Lukas Burgey committed
144
145
            json=data,
            auth=self.auth)
Lukas Burgey's avatar
Lukas Burgey committed
146
147
        req.raise_for_status()
        return req
Lukas Burgey's avatar
Lukas Burgey committed
148

Lukas Burgey's avatar
Lukas Burgey committed
149
    def _rest_del(self, api_path):
Lukas Burgey's avatar
Lukas Burgey committed
150
        req = requests.delete(
Lukas Burgey's avatar
Lukas Burgey committed
151
            self._get_api_uri(api_path),
Lukas Burgey's avatar
Lukas Burgey committed
152
            auth=self.auth)
Lukas Burgey's avatar
Lukas Burgey committed
153
154
        req.raise_for_status()
        return req
Lukas Burgey's avatar
Lukas Burgey committed
155

Lukas Burgey's avatar
Lukas Burgey committed
156
    def _set_topic_permissions(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
157
158
        username = site.client.username
        path = 'topic-permissions/{}/{}/'.format(
Lukas Burgey's avatar
Lukas Burgey committed
159
160
161
            self.vhost,
            username,
        )
Lukas Burgey's avatar
Lukas Burgey committed
162
163
164
165

        # set permissions for the correct topics
        # we construct a regex to match the services of the site
        services = ''
Lukas Burgey's avatar
Lukas Burgey committed
166
        omit_bar = True
Lukas Burgey's avatar
Lukas Burgey committed
167
168
        for service in site.services.all():
            prefix = '|'
Lukas Burgey's avatar
Lukas Burgey committed
169
            if omit_bar:
Lukas Burgey's avatar
Lukas Burgey committed
170
                prefix = ''
Lukas Burgey's avatar
Lukas Burgey committed
171
                omit_bar = False
Lukas Burgey's avatar
Lukas Burgey committed
172
173
174
175

            services = services + prefix + service.name

        set_topic_permission_data = {
Lukas Burgey's avatar
Lukas Burgey committed
176
177
            'exchange': self.exchange,
            'write': '^$',
Lukas Burgey's avatar
Lukas Burgey committed
178
            'read': r'^service\.({})$'.format(services),
Lukas Burgey's avatar
Lukas Burgey committed
179
        }
Lukas Burgey's avatar
Lukas Burgey committed
180

Lukas Burgey's avatar
Lukas Burgey committed
181
        return self._rest_put(path, set_topic_permission_data)
Lukas Burgey's avatar
Lukas Burgey committed
182
183

    # set permissions for the user
Lukas Burgey's avatar
Lukas Burgey committed
184
    def _set_permissions(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
185
186
        username = site.client.username
        path = 'permissions/{}/{}/'.format(
Lukas Burgey's avatar
Lukas Burgey committed
187
188
189
            self.vhost,
            username,
        )
Lukas Burgey's avatar
Lukas Burgey committed
190
        permission = r'^(amq\.gen.*|{})'.format(self.exchange)
Lukas Burgey's avatar
Lukas Burgey committed
191
        set_permission_data = {
Lukas Burgey's avatar
Lukas Burgey committed
192
193
194
195
            'configure': permission,
            'write': permission,
            'read': permission,
        }
Lukas Burgey's avatar
Lukas Burgey committed
196

Lukas Burgey's avatar
Lukas Burgey committed
197
        return self._rest_put(path, set_permission_data)
Lukas Burgey's avatar
Lukas Burgey committed
198
199

    # create user at the rabbitmq instance
Lukas Burgey's avatar
Lukas Burgey committed
200
    def _create_user(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
201
202
203
204
        username = site.client.username
        path = 'users/{}/'.format(username)

        user_creation_data = {
Lukas Burgey's avatar
Lukas Burgey committed
205
206
207
            'password': str(site.client.auth_token.key),
            'tags': '',
        }
Lukas Burgey's avatar
Lukas Burgey committed
208

Lukas Burgey's avatar
Lukas Burgey committed
209
        return self._rest_put(path, user_creation_data)
Lukas Burgey's avatar
Lukas Burgey committed
210
211

    # delete user at the rabbitmq instance
Lukas Burgey's avatar
Lukas Burgey committed
212
    def _delete_user(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
213
214
215
        username = site.client.username
        path = 'users/{}/'.format(username)

Lukas Burgey's avatar
Lukas Burgey committed
216
217
218
219
220
        return self._rest_del(path)

    def _disconnect(self):
        LOGGER.debug(self._msg('closing connection'))
        self.connection.close()
Lukas Burgey's avatar
Lukas Burgey committed
221
222
223
224

    # PUBLIC API

    def register_site(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
225
226
227
228
        self._create_user(site)
        self._set_permissions(site)
        self._set_topic_permissions(site)
        LOGGER.info(self._msg('registered {}'.format(site.client)))
Lukas Burgey's avatar
Lukas Burgey committed
229
230

    def update_site(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
231
232
        self._set_topic_permissions(site)
        LOGGER.info(self._msg('updated permissions for {}'.format(site.client)))
Lukas Burgey's avatar
Lukas Burgey committed
233
234

    def deregister_site(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
235
        self._delete_user(site)
Lukas Burgey's avatar
Lukas Burgey committed
236
        LOGGER.info(self._msg('deregistered {}'.format(site.client)))
Lukas Burgey's avatar
Lukas Burgey committed
237
238

    def is_client_connected(self, site):
Lukas Burgey's avatar
Lukas Burgey committed
239
        connections = self._rest_get("connections/")
Lukas Burgey's avatar
Lukas Burgey committed
240
241
242
243
244
245
246
247
248
249
        clients_for_site = [c
                            for c in connections
                            if c['user'] == site.client.username]
        return len(clients_for_site) > 0

    def online_clients(self, service):
        return [site
                for site in service.site.all()
                if self.is_client_connected(site)]

250
    def publish_by_service(self, service, msg):
251
252
253
254
255
256
257
258
259
260
261
262
263
        while True:
            try:
                return self.channel.basic_publish(
                    exchange=self.exchange,
                    routing_key=service.routing_key,
                    body=msg,
                    properties=pika.BasicProperties(
                        delivery_mode=1,
                    ),
                )
            except ConnectionClosed as exception:
                LOGGER.info(self._msg('ConnectionClosed: {}'.format(exception)))
                time.sleep(RECONNECT_TIMEOUT)
Lukas Burgey's avatar
Lukas Burgey committed
264
265


266
267
268
269
def user_info_default():
    return {}


Lukas Burgey's avatar
Lukas Burgey committed
270
class User(AbstractUser):
271
    TYPE_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
272
273
274
275
        ('apiclient', 'API-Client'),
        ('oidcuser', 'OIDC User'),
        ('admin', 'Admin'),
    )
276
    user_type = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
277
278
279
280
        max_length=20,
        choices=TYPE_CHOICES,
        default='oidcuser',
    )
281
282
283
284
    sub = models.CharField(
        max_length=150,
        blank=True,
        null=True,
285
        editable=False,
286
287
288
289
290
    )
    password = models.CharField(
        max_length=150,
        blank=True,
        null=True,
291
        editable=False,
292
293
294
295
296
297
298
    )
    # 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
299
300
301
302
303
    # the idp which authenticated the user
    idp = models.ForeignKey(
        OIDCConfig,
        related_name='users',
        on_delete=models.CASCADE,
304
305
306
        blank=True,
        null=True,
        editable=False,
Lukas Burgey's avatar
Lukas Burgey committed
307
    )
308
309
310
311
    userinfo = JSONField(
        default=user_info_default,
        null=True,
        blank=True,
312
        editable=False,
313
    )
Lukas Burgey's avatar
Lukas Burgey committed
314

Lukas Burgey's avatar
Lukas Burgey committed
315
    # we hide deleted keys here
316
    # the full list of ssh keys is self._ssh_keys
Lukas Burgey's avatar
Lukas Burgey committed
317
318
319
320
    @property
    def ssh_keys(self):
        return self._ssh_keys.filter(deleted=False)

321
322
323
324
    @property
    def is_active_at_clients(self):
        return self._is_active

325
326
327
    def __str__(self):
        if self.user_type == 'admin':
            return 'ADMIN {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
328
        elif self.user_type == 'oidcuser':
329
330
331
            if not self.is_active:
                return 'DEACTIVATED USER {}'.format(self.username)
            return 'USER {}'.format(self.username)
Lukas Burgey's avatar
Lukas Burgey committed
332
        elif self.user_type == 'apiclient':
333
            return 'APICLIENT {}@{}'.format(self.username, self.site)
Lukas Burgey's avatar
Lukas Burgey committed
334
335
        else:
            raise Exception()
Lukas Burgey's avatar
Lukas Burgey committed
336

Lukas Burgey's avatar
Lukas Burgey committed
337
    def _msg(self, msg):
338
339
340
341
342
343
        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
344
            LOGGER.info(self._msg('Deleting'))
345
346
347
348
349
350
351

            # TODO: deleting the user brings problems:
            # the deletion cascades down to DeploymentTask and DeploymentTaskItem
            # but these need to be conserved so all clients withdrawals can be tracked
            self.delete()

    def activate(self):
352
        if self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
353
            LOGGER.error(self._msg('already activated'))
354
355
356
357
            return

        if self.user_type == 'oidcuser':
            self.is_active = True
358
            self._is_active = True
359
360
361
362
363
            self.save()

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

Lukas Burgey's avatar
Lukas Burgey committed
364
            LOGGER.info(self._msg('activated'))
365
366
367

    # oidcuser: withdraw all credentials
    def deactivate(self):
368
        if not self._is_active:
Lukas Burgey's avatar
Lukas Burgey committed
369
            LOGGER.error(self._msg('already deactivated'))
370
371
372
373
            return

        if self.user_type == 'oidcuser':
            self.is_active = False
374
            self._is_active = False
375
376
377
378
379
            self.save()

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

Lukas Burgey's avatar
Lukas Burgey committed
380
            LOGGER.info(self._msg('deactivated'))
381
382


383
384
385
386
387
388
389
390
391
392
    @classmethod
    def construct_from_user_info(cls, user_info, idp):
        LOGGER.debug('User: constructing from %s', user_info)
        return cls(
            sub=user_info.get('sub', ''),
            first_name=user_info.get('given_name', ''),
            last_name=user_info.get('family_name', ''),
            email=user_info.get('email', ''),
            username=user_info.get('email', ''),
            idp=idp,
393
            userinfo=user_info,
394
        )
Lukas Burgey's avatar
Lukas Burgey committed
395
396
397


class Site(models.Model):
398
    client = models.OneToOneField(
Lukas Burgey's avatar
Lukas Burgey committed
399
400
401
        User,
        related_name='site',
    )
Lukas Burgey's avatar
Lukas Burgey committed
402
403
404
405
406
407
    name = models.CharField(max_length=150, unique=True)
    description = models.TextField(max_length=300, blank=True)

    def __str__(self):
        return self.name

408
409
410
411
412
413
    # 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
414

Lukas Burgey's avatar
Lukas Burgey committed
415
416
417
418

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

427
428
429
430
    @property
    def routing_key(self):
        return 'service.{}'.format(self.name)

Lukas Burgey's avatar
Lukas Burgey committed
431
    def __str__(self):
Lukas Burgey's avatar
Lukas Burgey committed
432
        return self.name
Lukas Burgey's avatar
Lukas Burgey committed
433
434
435


class SSHPublicKey(models.Model):
Lukas Burgey's avatar
Lukas Burgey committed
436
437
438
439
440
441
    name = models.CharField(
        max_length=150,
    )
    key = models.TextField(
        max_length=1000
    )
Lukas Burgey's avatar
Lukas Burgey committed
442
    # hidden field at the user
Lukas Burgey's avatar
Lukas Burgey committed
443
    user = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
444
445
446
        User,
        related_name='_ssh_keys',
    )
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 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

520
521
522
    @property
    def withdrawals(self):
        return self.tasks.filter(action='withdraw')
Lukas Burgey's avatar
Lukas Burgey committed
523

524
525
526
    @property
    def deploys(self):
        return self.tasks.filter(action='deploy')
Lukas Burgey's avatar
Lukas Burgey committed
527

528
529
    def __str__(self):
        return '{}:{}'.format(self.service, self.user)
530

Lukas Burgey's avatar
Lukas Burgey committed
531
    def _msg(self, msg):
532
        return '[Deployment:{}] {}'.format(self, msg)
533

534
535
536
    # deploy credentials which were deployed prior to deactivation
    def activate(self):
        if self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
537
            LOGGER.error(self._msg('already active'))
538
539
            return

Lukas Burgey's avatar
Lukas Burgey committed
540
        LOGGER.debug(self._msg(str(self.ssh_keys.all())))
541
542
543
544
545
        for key in self.ssh_keys.all():
            self._deploy_key(key)

        self.is_active = True
        self.save()
Lukas Burgey's avatar
Lukas Burgey committed
546
        LOGGER.info(self._msg('activated'))
547
548
549
550

    # withdraw all credentials
    def deactivate(self):
        if not self.is_active:
Lukas Burgey's avatar
Lukas Burgey committed
551
            LOGGER.error(self._msg('already deactivated'))
552
553
554
            return

        self.is_active = False
555
        self.save()
556

557
558
559
        for key in self.ssh_keys.all():
            self._withdraw_key(key)

Lukas Burgey's avatar
Lukas Burgey committed
560
        LOGGER.info(self._msg('deactivated'))
561
562
563
564

    # only deploy the key
    def _deploy_key(self, key):
        # delete outstanding tasks which are made obsolete by this task
565
        for withdrawal in self.withdrawals.filter(key=key):
Lukas Burgey's avatar
Lukas Burgey committed
566
            LOGGER.debug(withdrawal._msg('now obsolete'))
Lukas Burgey's avatar
Lukas Burgey committed
567
            withdrawal.delete()
568
569
570

        # generate task
        task = DeploymentTask(
Lukas Burgey's avatar
Lukas Burgey committed
571
572
573
574
            action='deploy',
            deployment=self,
            key=key,
        )
575
        task.save()
Lukas Burgey's avatar
Lukas Burgey committed
576
        LOGGER.debug(task._msg('generated'))
577
578
579
580

        # generate task items
        for site in self.service.site.all():
            deploy = DeploymentTaskItem(
Lukas Burgey's avatar
Lukas Burgey committed
581
582
583
                task=task,
                site=site,
            )
584
            deploy.save()
Lukas Burgey's avatar
Lukas Burgey committed
585
            LOGGER.debug(deploy._msg('generated'))
586
587
588

        # publish the task
        task.publish()
589

590
591
    def _withdraw_key(self, key):
        # delete outstanding tasks which are made obsolete by this task
592
        for deploy in self.deploys.filter(key=key):
Lukas Burgey's avatar
Lukas Burgey committed
593
            LOGGER.debug(deploy._msg("now obsolete"))
Lukas Burgey's avatar
Lukas Burgey committed
594
            deploy.delete()
Lukas Burgey's avatar
Lukas Burgey committed
595

596
597
        # generate task
        task = DeploymentTask(
Lukas Burgey's avatar
Lukas Burgey committed
598
599
600
601
            action='withdraw',
            deployment=self,
            key=key,
        )
602
        task.save()
Lukas Burgey's avatar
Lukas Burgey committed
603
        LOGGER.debug(task._msg('generated'))
Lukas Burgey's avatar
Lukas Burgey committed
604

605
606
607
        # generate task items
        for site in self.service.site.all():
            withdrawal = DeploymentTaskItem(
Lukas Burgey's avatar
Lukas Burgey committed
608
609
610
                task=task,
                site=site,
            )
611
            withdrawal.save()
Lukas Burgey's avatar
Lukas Burgey committed
612
            LOGGER.debug(withdrawal._msg('generated'))
613

614
615
        # publish the task
        task.publish()
Lukas Burgey's avatar
Lukas Burgey committed
616

617
618
619
    # 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
620
            LOGGER.error(self._msg('cannot deploy while deactivated'))
621
622
623
624
625
626
627
628
629
630
631
632
633
            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
634
            LOGGER.error(self._msg('cannot withdraw while deactivated'))
635
636
637
638
639
640
641
642
643
644
            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
645

646
647
class DeploymentTask(models.Model):
    ACTION_CHOICES = (
Lukas Burgey's avatar
Lukas Burgey committed
648
649
650
        ('deploy', 'deploy'),
        ('withdraw', 'withdraw'),
    )
651
    action = models.CharField(
Lukas Burgey's avatar
Lukas Burgey committed
652
653
654
        max_length=10,
        choices=ACTION_CHOICES,
    )
655
    key = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
656
657
658
659
        SSHPublicKey,
        related_name='tasks',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
660
    deployment = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
661
662
663
664
        Deployment,
        related_name='tasks',
        on_delete=models.CASCADE,
    )
Lukas Burgey's avatar
Lukas Burgey committed
665
666
667
668
669
670
671
672
673

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

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

674
    def __str__(self):
675
        return "{}:{}:{} - {}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
676
677
678
679
            self.deployment.service,
            self.deployment.user,
            self.key,
            self.action,
680
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
681
        )
682

Lukas Burgey's avatar
Lukas Burgey committed
683
    def _msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
684
        return '[DeploymentTask:{}] {}'.format(self, msg)
685
686

    def publish(self):
687
        # FIXME mitigating circular dependencies here
688
689
690
        from .clientapi.serializers import DeploymentTaskSerializer
        msg = json.dumps(DeploymentTaskSerializer(self).data)

691
        RabbitMQInstance.load().publish_by_service(
Lukas Burgey's avatar
Lukas Burgey committed
692
693
694
            self.service,
            msg,
        )
695
696
697

    # 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
698
        item = self.task_items.get(site=site)
Lukas Burgey's avatar
Lukas Burgey committed
699
        LOGGER.debug(item._msg('done'))
Lukas Burgey's avatar
Lukas Burgey committed
700
        item.delete()
701
702
703
704
705
706

        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
707
        LOGGER.info(self._msg('done'))
Lukas Burgey's avatar
Lukas Burgey committed
708
        self.delete()
709
710
711
712
713
714
715
716

        # 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
717
718
719
720
        DeploymentTask,
        related_name='task_items',
        on_delete=models.CASCADE,
    )
721
    site = models.ForeignKey(
Lukas Burgey's avatar
Lukas Burgey committed
722
723
724
725
        Site,
        related_name='task_items',
        on_delete=models.CASCADE,
    )
726

Lukas Burgey's avatar
Lukas Burgey committed
727
    def __str__(self):
728
        return "{}@{}#{}".format(
Lukas Burgey's avatar
Lukas Burgey committed
729
730
            self.task,
            self.site,
731
            self.id,
Lukas Burgey's avatar
Lukas Burgey committed
732
        )
733

Lukas Burgey's avatar
Lukas Burgey committed
734
    def _msg(self, msg):
Lukas Burgey's avatar
Lukas Burgey committed
735
        return '[DeploymentTaskItem:{}] {}'.format(self, msg)
736

Lukas Burgey's avatar
Lukas Burgey committed
737

738
739
740
#
# RECEIVERS
#
Lukas Burgey's avatar
Lukas Burgey committed
741
742
743
744
745
746
747
748

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_auth_token(sender, instance=None, created=False, **kwargs):
    if instance.user_type == 'apiclient' and created:
        Token.objects.create(user=instance)


@receiver(post_save, sender=Site)
Lukas Burgey's avatar
Lukas Burgey committed
749
def register_at_rabbitmq(sender, instance=None, created=False, **kwargs):
Lukas Burgey's avatar
Lukas Burgey committed
750
751
752
    if not created:
        return

753
    RabbitMQInstance.load().register_site(instance)
Lukas Burgey's avatar
Lukas Burgey committed
754
755
756


@receiver(pre_delete, sender=Site)
Lukas Burgey's avatar
Lukas Burgey committed
757
def deregister_at_rabbitmq(sender, instance=None, **kwargs):
758
759
760
761
762
763
764
    RabbitMQInstance.load().deregister_site(instance)


@receiver(post_save, sender=Service)
def update_at_rabbitmq(sender, instance=None, **kwargs):
    for site in instance.site.all():
        RabbitMQInstance.load().update_site(site)
765
766
767
768
769
770
771


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

772
    if not instance.is_active and instance.is_active_at_clients:
773
774
775
776
777
778
779
780
        instance.deactivate()


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

781
    if instance.is_active and not instance.is_active_at_clients:
782
        instance.activate()