github.com/rvaralda/deis@v1.4.1/controller/api/models.py (about) 1 # -*- coding: utf-8 -*- 2 3 """ 4 Data models for the Deis API. 5 """ 6 7 from __future__ import unicode_literals 8 import base64 9 import etcd 10 import importlib 11 import logging 12 import os 13 import re 14 import subprocess 15 import time 16 import threading 17 18 from django.conf import settings 19 from django.contrib.auth import get_user_model 20 from django.core.exceptions import ValidationError 21 from django.db import models 22 from django.db.models import Count 23 from django.db.models import Max 24 from django.db.models.signals import post_delete, post_save 25 from django.dispatch import receiver 26 from django.utils.encoding import python_2_unicode_compatible 27 from docker.utils import utils as dockerutils 28 from json_field.fields import JSONField 29 import requests 30 from rest_framework.authtoken.models import Token 31 32 from api import fields, utils 33 from registry import publish_release 34 from utils import dict_diff, fingerprint 35 36 37 logger = logging.getLogger(__name__) 38 39 40 def close_db_connections(func, *args, **kwargs): 41 """ 42 Decorator to explicitly close db connections during threaded execution 43 44 Note this is necessary to work around: 45 https://code.djangoproject.com/ticket/22420 46 """ 47 def _close_db_connections(*args, **kwargs): 48 ret = None 49 try: 50 ret = func(*args, **kwargs) 51 finally: 52 from django.db import connections 53 for conn in connections.all(): 54 conn.close() 55 return ret 56 return _close_db_connections 57 58 59 def log_event(app, msg, level=logging.INFO): 60 msg = "{}: {}".format(app.id, msg) 61 logger.log(level, msg) # django logger 62 app.log(msg) # local filesystem 63 64 65 def validate_base64(value): 66 """Check that value contains only valid base64 characters.""" 67 try: 68 base64.b64decode(value.split()[1]) 69 except Exception as e: 70 raise ValidationError(e) 71 72 73 def validate_id_is_docker_compatible(value): 74 """ 75 Check that the ID follows docker's image name constraints 76 """ 77 match = re.match(r'^[a-z0-9-]+$', value) 78 if not match: 79 raise ValidationError("App IDs can only contain [a-z0-9-].") 80 81 82 def validate_app_structure(value): 83 """Error if the dict values aren't ints >= 0.""" 84 try: 85 for k, v in value.iteritems(): 86 if int(v) < 0: 87 raise ValueError("Must be greater than or equal to zero") 88 except ValueError, err: 89 raise ValidationError(err) 90 91 92 def validate_reserved_names(value): 93 """A value cannot use some reserved names.""" 94 if value in ['deis']: 95 raise ValidationError('{} is a reserved name.'.format(value)) 96 97 98 def validate_comma_separated(value): 99 """Error if the value doesn't look like a list of hostnames or IP addresses 100 separated by commas. 101 """ 102 if not re.search(r'^[a-zA-Z0-9-,\.]+$', value): 103 raise ValidationError( 104 "{} should be a comma-separated list".format(value)) 105 106 107 def validate_domain(value): 108 """Error if the domain contains unexpected characters.""" 109 if not re.search(r'^[a-zA-Z0-9-\.]+$', value): 110 raise ValidationError('"{}" contains unexpected characters'.format(value)) 111 112 113 class AuditedModel(models.Model): 114 """Add created and updated fields to a model.""" 115 116 created = models.DateTimeField(auto_now_add=True) 117 updated = models.DateTimeField(auto_now=True) 118 119 class Meta: 120 """Mark :class:`AuditedModel` as abstract.""" 121 abstract = True 122 123 124 class UuidAuditedModel(AuditedModel): 125 """Add a UUID primary key to an :class:`AuditedModel`.""" 126 127 uuid = fields.UuidField('UUID', primary_key=True) 128 129 class Meta: 130 """Mark :class:`UuidAuditedModel` as abstract.""" 131 abstract = True 132 133 134 @python_2_unicode_compatible 135 class App(UuidAuditedModel): 136 """ 137 Application used to service requests on behalf of end-users 138 """ 139 140 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 141 id = models.SlugField(max_length=64, unique=True, default=utils.generate_app_name, 142 validators=[validate_id_is_docker_compatible, 143 validate_reserved_names]) 144 structure = JSONField(default={}, blank=True, validators=[validate_app_structure]) 145 146 class Meta: 147 permissions = (('use_app', 'Can use app'),) 148 149 @property 150 def _scheduler(self): 151 mod = importlib.import_module(settings.SCHEDULER_MODULE) 152 return mod.SchedulerClient(settings.SCHEDULER_TARGET, 153 settings.SCHEDULER_AUTH, 154 settings.SCHEDULER_OPTIONS, 155 settings.SSH_PRIVATE_KEY) 156 157 def __str__(self): 158 return self.id 159 160 @property 161 def url(self): 162 return self.id + '.' + settings.DEIS_DOMAIN 163 164 def log(self, message): 165 """Logs a message to the application's log file. 166 167 This is a workaround for how Django interacts with Python's logging module. Each app 168 needs its own FileHandler instance so it can write to its own log file. That won't work in 169 Django's case because logging is set up before you run the server and it disables all 170 existing logging configurations. 171 """ 172 with open(os.path.join(settings.DEIS_LOG_DIR, self.id + '.log'), 'a') as f: 173 msg = "{} deis[api]: {}\n".format(time.strftime(settings.DEIS_DATETIME_FORMAT), 174 message) 175 f.write(msg.encode('utf-8')) 176 177 def create(self, *args, **kwargs): 178 """Create a new application with an initial config and release""" 179 config = Config.objects.create(owner=self.owner, app=self) 180 Release.objects.create(version=1, owner=self.owner, app=self, config=config, build=None) 181 182 def delete(self, *args, **kwargs): 183 """Delete this application including all containers""" 184 try: 185 # attempt to remove containers from the scheduler 186 self._destroy_containers([c for c in self.container_set.exclude(type='run')]) 187 except RuntimeError: 188 pass 189 self._clean_app_logs() 190 return super(App, self).delete(*args, **kwargs) 191 192 def _clean_app_logs(self): 193 """Delete application logs stored by the logger component""" 194 path = os.path.join(settings.DEIS_LOG_DIR, self.id + '.log') 195 if os.path.exists(path): 196 os.remove(path) 197 198 def scale(self, user, structure): # noqa 199 """Scale containers up or down to match requested structure.""" 200 if self.release_set.latest().build is None: 201 raise EnvironmentError('No build associated with this release') 202 requested_structure = structure.copy() 203 release = self.release_set.latest() 204 # test for available process types 205 available_process_types = release.build.procfile or {} 206 for container_type in requested_structure.keys(): 207 if container_type == 'cmd': 208 continue # allow docker cmd types in case we don't have the image source 209 if container_type not in available_process_types: 210 raise EnvironmentError( 211 'Container type {} does not exist in application'.format(container_type)) 212 msg = '{} scaled containers '.format(user.username) + ' '.join( 213 "{}={}".format(k, v) for k, v in requested_structure.items()) 214 log_event(self, msg) 215 # iterate and scale by container type (web, worker, etc) 216 changed = False 217 to_add, to_remove = [], [] 218 for container_type in requested_structure.keys(): 219 containers = list(self.container_set.filter(type=container_type).order_by('created')) 220 # increment new container nums off the most recent container 221 results = self.container_set.filter(type=container_type).aggregate(Max('num')) 222 container_num = (results.get('num__max') or 0) + 1 223 requested = requested_structure.pop(container_type) 224 diff = requested - len(containers) 225 if diff == 0: 226 continue 227 changed = True 228 while diff < 0: 229 c = containers.pop() 230 to_remove.append(c) 231 diff += 1 232 while diff > 0: 233 # create a database record 234 c = Container.objects.create(owner=self.owner, 235 app=self, 236 release=release, 237 type=container_type, 238 num=container_num) 239 to_add.append(c) 240 container_num += 1 241 diff -= 1 242 if changed: 243 if to_add: 244 self._start_containers(to_add) 245 if to_remove: 246 self._destroy_containers(to_remove) 247 # save new structure to the database 248 vals = self.container_set.exclude(type='run').values( 249 'type').annotate(Count('pk')).order_by() 250 self.structure = {v['type']: v['pk__count'] for v in vals} 251 self.save() 252 return changed 253 254 def _start_containers(self, to_add): 255 """Creates and starts containers via the scheduler""" 256 create_threads = [] 257 start_threads = [] 258 if not to_add: 259 # do nothing if we didn't request any containers 260 return 261 for c in to_add: 262 create_threads.append(threading.Thread(target=c.create)) 263 start_threads.append(threading.Thread(target=c.start)) 264 [t.start() for t in create_threads] 265 [t.join() for t in create_threads] 266 if set([c.state for c in to_add]) != set(['created']): 267 err = 'aborting, failed to create some containers' 268 log_event(self, err, logging.ERROR) 269 raise RuntimeError(err) 270 [t.start() for t in start_threads] 271 [t.join() for t in start_threads] 272 if set([c.state for c in to_add]) != set(['up']): 273 err = 'warning, some containers failed to start' 274 log_event(self, err, logging.WARNING) 275 276 def _destroy_containers(self, to_destroy): 277 """Destroys containers via the scheduler""" 278 destroy_threads = [] 279 if not to_destroy: 280 # do nothing if we didn't request any containers 281 return 282 for c in to_destroy: 283 destroy_threads.append(threading.Thread(target=c.destroy)) 284 [t.start() for t in destroy_threads] 285 [t.join() for t in destroy_threads] 286 [c.delete() for c in to_destroy if c.state == 'destroyed'] 287 if set([c.state for c in to_destroy]) != set(['destroyed']): 288 err = 'aborting, failed to destroy some containers' 289 log_event(self, err, logging.ERROR) 290 raise RuntimeError(err) 291 292 def deploy(self, user, release, initial=False): 293 """Deploy a new release to this application""" 294 existing = self.container_set.exclude(type='run') 295 new = [] 296 for e in existing: 297 n = e.clone(release) 298 n.save() 299 new.append(n) 300 301 self._start_containers(new) 302 303 # destroy old containers 304 if existing: 305 self._destroy_containers(existing) 306 307 # perform default scaling if necessary 308 if initial: 309 self._default_scale(user, release) 310 311 def _default_scale(self, user, release): 312 """Scale to default structure based on release type""" 313 # if there is no SHA, assume a docker image is being promoted 314 if not release.build.sha: 315 structure = {'cmd': 1} 316 317 # if a dockerfile exists without a procfile, assume docker workflow 318 elif release.build.dockerfile and not release.build.procfile: 319 structure = {'cmd': 1} 320 321 # if a procfile exists without a web entry, assume docker workflow 322 elif release.build.procfile and 'web' not in release.build.procfile: 323 structure = {'cmd': 1} 324 325 # default to heroku workflow 326 else: 327 structure = {'web': 1} 328 329 self.scale(user, structure) 330 331 def logs(self): 332 """Return aggregated log data for this application.""" 333 path = os.path.join(settings.DEIS_LOG_DIR, self.id + '.log') 334 if not os.path.exists(path): 335 raise EnvironmentError('Could not locate logs') 336 data = subprocess.check_output(['tail', '-n', str(settings.LOG_LINES), path]) 337 return data 338 339 def run(self, user, command): 340 """Run a one-off command in an ephemeral app container.""" 341 # FIXME: remove the need for SSH private keys by using 342 # a scheduler that supports one-off admin tasks natively 343 if not settings.SSH_PRIVATE_KEY: 344 raise EnvironmentError('Support for admin commands is not configured') 345 if self.release_set.latest().build is None: 346 raise EnvironmentError('No build associated with this release to run this command') 347 # TODO: add support for interactive shell 348 msg = "{} runs '{}'".format(user.username, command) 349 log_event(self, msg) 350 c_num = max([c.num for c in self.container_set.filter(type='run')] or [0]) + 1 351 352 # create database record for run process 353 c = Container.objects.create(owner=self.owner, 354 app=self, 355 release=self.release_set.latest(), 356 type='run', 357 num=c_num) 358 image = c.release.image 359 360 # check for backwards compatibility 361 def _has_hostname(image): 362 repo, tag = dockerutils.parse_repository_tag(image) 363 return True if '/' in repo and '.' in repo.split('/')[0] else False 364 365 if not _has_hostname(image): 366 image = '{}:{}/{}'.format(settings.REGISTRY_HOST, 367 settings.REGISTRY_PORT, 368 image) 369 # SECURITY: shell-escape user input 370 escaped_command = command.replace("'", "'\\''") 371 return c.run(escaped_command) 372 373 374 @python_2_unicode_compatible 375 class Container(UuidAuditedModel): 376 """ 377 Docker container used to securely host an application process. 378 """ 379 380 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 381 app = models.ForeignKey('App') 382 release = models.ForeignKey('Release') 383 type = models.CharField(max_length=128, blank=False) 384 num = models.PositiveIntegerField() 385 386 @property 387 def _scheduler(self): 388 return self.app._scheduler 389 390 @property 391 def state(self): 392 return self._scheduler.state(self._job_id).name 393 394 def short_name(self): 395 return "{}.{}.{}".format(self.app.id, self.type, self.num) 396 short_name.short_description = 'Name' 397 398 def __str__(self): 399 return self.short_name() 400 401 class Meta: 402 get_latest_by = '-created' 403 ordering = ['created'] 404 405 def _get_job_id(self): 406 app = self.app.id 407 release = self.release 408 version = "v{}".format(release.version) 409 num = self.num 410 job_id = "{app}_{version}.{self.type}.{num}".format(**locals()) 411 return job_id 412 413 _job_id = property(_get_job_id) 414 415 def _get_command(self): 416 try: 417 # if this is not procfile-based app, ensure they cannot break out 418 # and run arbitrary commands on the host 419 # FIXME: remove slugrunner's hardcoded entrypoint 420 if self.release.build.dockerfile or not self.release.build.sha: 421 return "bash -c '{}'".format(self.release.build.procfile[self.type]) 422 else: 423 return 'start {}'.format(self.type) 424 # if the key is not present or if a parent attribute is None 425 except (KeyError, TypeError, AttributeError): 426 # handle special case for Dockerfile deployments 427 return '' if self.type == 'cmd' else 'start {}'.format(self.type) 428 429 _command = property(_get_command) 430 431 def clone(self, release): 432 c = Container.objects.create(owner=self.owner, 433 app=self.app, 434 release=release, 435 type=self.type, 436 num=self.num) 437 return c 438 439 @close_db_connections 440 def create(self): 441 image = self.release.image 442 kwargs = {'memory': self.release.config.memory, 443 'cpu': self.release.config.cpu, 444 'tags': self.release.config.tags} 445 job_id = self._job_id 446 try: 447 self._scheduler.create( 448 name=job_id, 449 image=image, 450 command=self._command, 451 **kwargs) 452 except Exception as e: 453 err = '{} (create): {}'.format(job_id, e) 454 log_event(self.app, err, logging.ERROR) 455 raise 456 457 @close_db_connections 458 def start(self): 459 job_id = self._job_id 460 try: 461 self._scheduler.start(job_id) 462 except Exception as e: 463 err = '{} (start): {}'.format(job_id, e) 464 log_event(self.app, err, logging.WARNING) 465 raise 466 467 @close_db_connections 468 def stop(self): 469 job_id = self._job_id 470 try: 471 self._scheduler.stop(job_id) 472 except Exception as e: 473 err = '{} (stop): {}'.format(job_id, e) 474 log_event(self.app, err, logging.ERROR) 475 raise 476 477 @close_db_connections 478 def destroy(self): 479 job_id = self._job_id 480 try: 481 self._scheduler.destroy(job_id) 482 except Exception as e: 483 err = '{} (destroy): {}'.format(job_id, e) 484 log_event(self.app, err, logging.ERROR) 485 raise 486 487 def run(self, command): 488 """Run a one-off command""" 489 if self.release.build is None: 490 raise EnvironmentError('No build associated with this release ' 491 'to run this command') 492 image = self.release.image 493 job_id = self._job_id 494 entrypoint = '/bin/bash' 495 # if this is a procfile-based app, switch the entrypoint to slugrunner's default 496 # FIXME: remove slugrunner's hardcoded entrypoint 497 if self.release.build.procfile and \ 498 self.release.build.sha and not \ 499 self.release.build.dockerfile: 500 entrypoint = '/runner/init' 501 command = "'{}'".format(command) 502 else: 503 command = "-c '{}'".format(command) 504 try: 505 rc, output = self._scheduler.run(job_id, image, entrypoint, command) 506 return rc, output 507 except Exception as e: 508 err = '{} (run): {}'.format(job_id, e) 509 log_event(self.app, err, logging.ERROR) 510 raise 511 512 513 @python_2_unicode_compatible 514 class Push(UuidAuditedModel): 515 """ 516 Instance of a push used to trigger an application build 517 """ 518 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 519 app = models.ForeignKey('App') 520 sha = models.CharField(max_length=40) 521 522 fingerprint = models.CharField(max_length=255) 523 receive_user = models.CharField(max_length=255) 524 receive_repo = models.CharField(max_length=255) 525 526 ssh_connection = models.CharField(max_length=255) 527 ssh_original_command = models.CharField(max_length=255) 528 529 class Meta: 530 get_latest_by = 'created' 531 ordering = ['-created'] 532 unique_together = (('app', 'uuid'),) 533 534 def __str__(self): 535 return "{0}-{1}".format(self.app.id, self.sha[:7]) 536 537 538 @python_2_unicode_compatible 539 class Build(UuidAuditedModel): 540 """ 541 Instance of a software build used by runtime nodes 542 """ 543 544 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 545 app = models.ForeignKey('App') 546 image = models.CharField(max_length=256) 547 548 # optional fields populated by builder 549 sha = models.CharField(max_length=40, blank=True) 550 procfile = JSONField(default={}, blank=True) 551 dockerfile = models.TextField(blank=True) 552 553 class Meta: 554 get_latest_by = 'created' 555 ordering = ['-created'] 556 unique_together = (('app', 'uuid'),) 557 558 def create(self, user, *args, **kwargs): 559 latest_release = self.app.release_set.latest() 560 source_version = 'latest' 561 if self.sha: 562 source_version = 'git-{}'.format(self.sha) 563 new_release = latest_release.new(user, 564 build=self, 565 config=latest_release.config, 566 source_version=source_version) 567 initial = True if self.app.structure == {} else False 568 try: 569 self.app.deploy(user, new_release, initial=initial) 570 return new_release 571 except RuntimeError: 572 new_release.delete() 573 raise 574 575 def save(self, **kwargs): 576 try: 577 previous_build = self.app.build_set.latest() 578 to_destroy = [] 579 for proctype in previous_build.procfile.keys(): 580 if proctype not in self.procfile.keys(): 581 for c in self.app.container_set.filter(type=proctype): 582 to_destroy.append(c) 583 self.app._destroy_containers(to_destroy) 584 except Build.DoesNotExist: 585 pass 586 return super(Build, self).save(**kwargs) 587 588 def __str__(self): 589 return "{0}-{1}".format(self.app.id, self.uuid[:7]) 590 591 592 @python_2_unicode_compatible 593 class Config(UuidAuditedModel): 594 """ 595 Set of configuration values applied as environment variables 596 during runtime execution of the Application. 597 """ 598 599 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 600 app = models.ForeignKey('App') 601 values = JSONField(default={}, blank=True) 602 memory = JSONField(default={}, blank=True) 603 cpu = JSONField(default={}, blank=True) 604 tags = JSONField(default={}, blank=True) 605 606 class Meta: 607 get_latest_by = 'created' 608 ordering = ['-created'] 609 unique_together = (('app', 'uuid'),) 610 611 def __str__(self): 612 return "{}-{}".format(self.app.id, self.uuid[:7]) 613 614 def save(self, **kwargs): 615 """merge the old config with the new""" 616 try: 617 previous_config = self.app.config_set.latest() 618 for attr in ['cpu', 'memory', 'tags', 'values']: 619 # Guard against migrations from older apps without fixes to 620 # JSONField encoding. 621 try: 622 data = getattr(previous_config, attr).copy() 623 except AttributeError: 624 data = {} 625 try: 626 new_data = getattr(self, attr).copy() 627 except AttributeError: 628 new_data = {} 629 data.update(new_data) 630 # remove config keys if we provided a null value 631 [data.pop(k) for k, v in new_data.items() if v is None] 632 setattr(self, attr, data) 633 except Config.DoesNotExist: 634 pass 635 return super(Config, self).save(**kwargs) 636 637 638 @python_2_unicode_compatible 639 class Release(UuidAuditedModel): 640 """ 641 Software release deployed by the application platform 642 643 Releases contain a :class:`Build` and a :class:`Config`. 644 """ 645 646 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 647 app = models.ForeignKey('App') 648 version = models.PositiveIntegerField() 649 summary = models.TextField(blank=True, null=True) 650 651 config = models.ForeignKey('Config') 652 build = models.ForeignKey('Build', null=True) 653 654 class Meta: 655 get_latest_by = 'created' 656 ordering = ['-created'] 657 unique_together = (('app', 'version'),) 658 659 def __str__(self): 660 return "{0}-v{1}".format(self.app.id, self.version) 661 662 @property 663 def image(self): 664 return '{}:v{}'.format(self.app.id, str(self.version)) 665 666 def new(self, user, config, build, summary=None, source_version='latest'): 667 """ 668 Create a new application release using the provided Build and Config 669 on behalf of a user. 670 671 Releases start at v1 and auto-increment. 672 """ 673 # construct fully-qualified target image 674 new_version = self.version + 1 675 # create new release and auto-increment version 676 release = Release.objects.create( 677 owner=user, app=self.app, config=config, 678 build=build, version=new_version, summary=summary) 679 try: 680 release.publish() 681 except EnvironmentError as e: 682 # If we cannot publish this app, just log and carry on 683 logger.info(e) 684 pass 685 return release 686 687 def publish(self, source_version='latest'): 688 if self.build is None: 689 raise EnvironmentError('No build associated with this release to publish') 690 source_tag = 'git-{}'.format(self.build.sha) if self.build.sha else source_version 691 source_image = '{}:{}'.format(self.build.image, source_tag) 692 # IOW, this image did not come from the builder 693 # FIXME: remove check for mock registry module 694 if not self.build.sha and 'mock' not in settings.REGISTRY_MODULE: 695 # we assume that the image is not present on our registry, 696 # so shell out a task to pull in the repository 697 data = { 698 'src': self.build.image 699 } 700 requests.post( 701 '{}/v1/repositories/{}/tags'.format(settings.REGISTRY_URL, 702 self.app.id), 703 data=data, 704 ) 705 # update the source image to the repository we just imported 706 source_image = self.app.id 707 # if the image imported had a tag specified, use that tag as the source 708 if ':' in self.build.image: 709 if '/' not in self.build.image[self.build.image.rfind(':') + 1:]: 710 source_image += self.build.image[self.build.image.rfind(':'):] 711 publish_release(source_image, 712 self.config.values, 713 self.image) 714 715 def previous(self): 716 """ 717 Return the previous Release to this one. 718 719 :return: the previous :class:`Release`, or None 720 """ 721 releases = self.app.release_set 722 if self.pk: 723 releases = releases.exclude(pk=self.pk) 724 try: 725 # Get the Release previous to this one 726 prev_release = releases.latest() 727 except Release.DoesNotExist: 728 prev_release = None 729 return prev_release 730 731 def rollback(self, user, version): 732 if version < 1: 733 raise EnvironmentError('version cannot be below 0') 734 summary = "{} rolled back to v{}".format(user, version) 735 prev = self.app.release_set.get(version=version) 736 new_release = self.new( 737 user, 738 build=prev.build, 739 config=prev.config, 740 summary=summary, 741 source_version='v{}'.format(version)) 742 try: 743 self.app.deploy(user, new_release) 744 return new_release 745 except RuntimeError: 746 new_release.delete() 747 raise 748 749 def save(self, *args, **kwargs): # noqa 750 if not self.summary: 751 self.summary = '' 752 prev_release = self.previous() 753 # compare this build to the previous build 754 old_build = prev_release.build if prev_release else None 755 old_config = prev_release.config if prev_release else None 756 # if the build changed, log it and who pushed it 757 if self.version == 1: 758 self.summary += "{} created initial release".format(self.app.owner) 759 elif self.build != old_build: 760 if self.build.sha: 761 self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7]) 762 else: 763 self.summary += "{} deployed {}".format(self.build.owner, self.build.image) 764 # if the config data changed, log the dict diff 765 if self.config != old_config: 766 dict1 = self.config.values 767 dict2 = old_config.values if old_config else {} 768 diff = dict_diff(dict1, dict2) 769 # try to be as succinct as possible 770 added = ', '.join(k for k in diff.get('added', {})) 771 added = 'added ' + added if added else '' 772 changed = ', '.join(k for k in diff.get('changed', {})) 773 changed = 'changed ' + changed if changed else '' 774 deleted = ', '.join(k for k in diff.get('deleted', {})) 775 deleted = 'deleted ' + deleted if deleted else '' 776 changes = ', '.join(i for i in (added, changed, deleted) if i) 777 if changes: 778 if self.summary: 779 self.summary += ' and ' 780 self.summary += "{} {}".format(self.config.owner, changes) 781 # if the limits changed (memory or cpu), log the dict diff 782 changes = [] 783 old_mem = old_config.memory if old_config else {} 784 diff = dict_diff(self.config.memory, old_mem) 785 if diff.get('added') or diff.get('changed') or diff.get('deleted'): 786 changes.append('memory') 787 old_cpu = old_config.cpu if old_config else {} 788 diff = dict_diff(self.config.cpu, old_cpu) 789 if diff.get('added') or diff.get('changed') or diff.get('deleted'): 790 changes.append('cpu') 791 if changes: 792 changes = 'changed limits for '+', '.join(changes) 793 self.summary += "{} {}".format(self.config.owner, changes) 794 # if the tags changed, log the dict diff 795 changes = [] 796 old_tags = old_config.tags if old_config else {} 797 diff = dict_diff(self.config.tags, old_tags) 798 # try to be as succinct as possible 799 added = ', '.join(k for k in diff.get('added', {})) 800 added = 'added tag ' + added if added else '' 801 changed = ', '.join(k for k in diff.get('changed', {})) 802 changed = 'changed tag ' + changed if changed else '' 803 deleted = ', '.join(k for k in diff.get('deleted', {})) 804 deleted = 'deleted tag ' + deleted if deleted else '' 805 changes = ', '.join(i for i in (added, changed, deleted) if i) 806 if changes: 807 if self.summary: 808 self.summary += ' and ' 809 self.summary += "{} {}".format(self.config.owner, changes) 810 if not self.summary: 811 if self.version == 1: 812 self.summary = "{} created the initial release".format(self.owner) 813 else: 814 self.summary = "{} changed nothing".format(self.owner) 815 super(Release, self).save(*args, **kwargs) 816 817 818 @python_2_unicode_compatible 819 class Domain(AuditedModel): 820 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 821 app = models.ForeignKey('App') 822 domain = models.TextField(blank=False, null=False, unique=True) 823 824 def __str__(self): 825 return self.domain 826 827 828 @python_2_unicode_compatible 829 class Key(UuidAuditedModel): 830 """An SSH public key.""" 831 832 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 833 id = models.CharField(max_length=128) 834 public = models.TextField(unique=True, validators=[validate_base64]) 835 836 class Meta: 837 verbose_name = 'SSH Key' 838 unique_together = (('owner', 'id')) 839 840 def __str__(self): 841 return "{}...{}".format(self.public[:18], self.public[-31:]) 842 843 844 # define update/delete callbacks for synchronizing 845 # models with the configuration management backend 846 847 def _log_build_created(**kwargs): 848 if kwargs.get('created'): 849 build = kwargs['instance'] 850 log_event(build.app, "build {} created".format(build)) 851 852 853 def _log_release_created(**kwargs): 854 if kwargs.get('created'): 855 release = kwargs['instance'] 856 log_event(release.app, "release {} created".format(release)) 857 # append release lifecycle logs to the app 858 release.app.log(release.summary) 859 860 861 def _log_config_updated(**kwargs): 862 config = kwargs['instance'] 863 log_event(config.app, "config {} updated".format(config)) 864 865 866 def _log_domain_added(**kwargs): 867 domain = kwargs['instance'] 868 msg = "domain {} added".format(domain) 869 log_event(domain.app, msg) 870 # adding a domain does not create a release, so we have to log here 871 domain.app.log(msg) 872 873 874 def _log_domain_removed(**kwargs): 875 domain = kwargs['instance'] 876 msg = "domain {} removed".format(domain) 877 log_event(domain.app, msg) 878 # adding a domain does not create a release, so we have to log here 879 domain.app.log(msg) 880 881 882 def _etcd_publish_key(**kwargs): 883 key = kwargs['instance'] 884 _etcd_client.write('/deis/builder/users/{}/{}'.format( 885 key.owner.username, fingerprint(key.public)), key.public) 886 887 888 def _etcd_purge_key(**kwargs): 889 key = kwargs['instance'] 890 _etcd_client.delete('/deis/builder/users/{}/{}'.format( 891 key.owner.username, fingerprint(key.public))) 892 893 894 def _etcd_purge_user(**kwargs): 895 username = kwargs['instance'].username 896 try: 897 _etcd_client.delete( 898 '/deis/builder/users/{}'.format(username), dir=True, recursive=True) 899 except KeyError: 900 # If _etcd_publish_key() wasn't called, there is no user dir to delete. 901 pass 902 903 904 def _etcd_create_app(**kwargs): 905 appname = kwargs['instance'] 906 if kwargs['created']: 907 _etcd_client.write('/deis/services/{}'.format(appname), None, dir=True) 908 909 910 def _etcd_purge_app(**kwargs): 911 appname = kwargs['instance'] 912 _etcd_client.delete('/deis/services/{}'.format(appname), dir=True, recursive=True) 913 914 915 def _etcd_publish_domains(**kwargs): 916 app = kwargs['instance'].app 917 app_domains = app.domain_set.all() 918 if app_domains: 919 _etcd_client.write('/deis/domains/{}'.format(app), 920 ' '.join(str(d.domain) for d in app_domains)) 921 922 923 def _etcd_purge_domains(**kwargs): 924 app = kwargs['instance'].app 925 app_domains = app.domain_set.all() 926 if app_domains: 927 _etcd_client.write('/deis/domains/{}'.format(app), 928 ' '.join(str(d.domain) for d in app_domains)) 929 else: 930 _etcd_client.delete('/deis/domains/{}'.format(app)) 931 932 933 # Log significant app-related events 934 post_save.connect(_log_build_created, sender=Build, dispatch_uid='api.models.log') 935 post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log') 936 post_save.connect(_log_config_updated, sender=Config, dispatch_uid='api.models.log') 937 post_save.connect(_log_domain_added, sender=Domain, dispatch_uid='api.models.log') 938 post_delete.connect(_log_domain_removed, sender=Domain, dispatch_uid='api.models.log') 939 940 941 # automatically generate a new token on creation 942 @receiver(post_save, sender=get_user_model()) 943 def create_auth_token(sender, instance=None, created=False, **kwargs): 944 if created: 945 Token.objects.create(user=instance) 946 947 # wire up etcd publishing if we can connect 948 try: 949 _etcd_client = etcd.Client(host=settings.ETCD_HOST, port=int(settings.ETCD_PORT)) 950 _etcd_client.get('/deis') 951 except etcd.EtcdException: 952 logger.log(logging.WARNING, 'Cannot synchronize with etcd cluster') 953 _etcd_client = None 954 955 if _etcd_client: 956 post_save.connect(_etcd_publish_key, sender=Key, dispatch_uid='api.models') 957 post_delete.connect(_etcd_purge_key, sender=Key, dispatch_uid='api.models') 958 post_delete.connect(_etcd_purge_user, sender=get_user_model(), dispatch_uid='api.models') 959 post_save.connect(_etcd_publish_domains, sender=Domain, dispatch_uid='api.models') 960 post_delete.connect(_etcd_purge_domains, sender=Domain, dispatch_uid='api.models') 961 post_save.connect(_etcd_create_app, sender=App, dispatch_uid='api.models') 962 post_delete.connect(_etcd_purge_app, sender=App, dispatch_uid='api.models')