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