github.com/blystad/deis@v0.11.0/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 15 from celery.canvas import group 16 from django.conf import settings 17 from django.contrib.auth.models import User 18 from django.core.exceptions import ValidationError 19 from django.db import models, connections 20 from django.db.models import Max 21 from django.db.models.signals import post_delete 22 from django.db.models.signals import post_save 23 from django.utils.encoding import python_2_unicode_compatible 24 from django_fsm import FSMField, transition 25 from django_fsm.signals import post_transition 26 from json_field.fields import JSONField 27 28 from api import fields, tasks 29 from registry import publish_release 30 from utils import dict_diff, fingerprint 31 32 33 logger = logging.getLogger(__name__) 34 35 36 def log_event(app, msg, level=logging.INFO): 37 msg = "{}: {}".format(app.id, msg) 38 logger.log(level, msg) 39 40 41 def close_db_connections(func, *args, **kwargs): 42 """ 43 Decorator to close db connections during threaded execution 44 45 Note this is necessary to work around: 46 https://code.djangoproject.com/ticket/22420 47 """ 48 def _inner(*args, **kwargs): 49 func(*args, **kwargs) 50 for conn in connections.all(): 51 conn.close() 52 return _inner 53 54 55 def validate_app_structure(value): 56 """Error if the dict values aren't ints >= 0.""" 57 try: 58 for k, v in value.iteritems(): 59 if int(v) < 0: 60 raise ValueError("Must be greater than or equal to zero") 61 except ValueError, err: 62 raise ValidationError(err) 63 64 65 def validate_comma_separated(value): 66 """Error if the value doesn't look like a list of hostnames or IP addresses 67 separated by commas. 68 """ 69 if not re.search(r'^[a-zA-Z0-9-,\.]+$', value): 70 raise ValidationError( 71 "{} should be a comma-separated list".format(value)) 72 73 74 def validate_domain(value): 75 """Error if the domain contains unexpected characters.""" 76 if not re.search(r'^[a-zA-Z0-9-\.]+$', value): 77 raise ValidationError('"{}" contains unexpected characters'.format(value)) 78 79 80 class AuditedModel(models.Model): 81 """Add created and updated fields to a model.""" 82 83 created = models.DateTimeField(auto_now_add=True) 84 updated = models.DateTimeField(auto_now=True) 85 86 class Meta: 87 """Mark :class:`AuditedModel` as abstract.""" 88 abstract = True 89 90 91 class UuidAuditedModel(AuditedModel): 92 """Add a UUID primary key to an :class:`AuditedModel`.""" 93 94 uuid = fields.UuidField('UUID', primary_key=True) 95 96 class Meta: 97 """Mark :class:`UuidAuditedModel` as abstract.""" 98 abstract = True 99 100 101 @python_2_unicode_compatible 102 class Cluster(UuidAuditedModel): 103 """ 104 Cluster used to run jobs 105 """ 106 107 CLUSTER_TYPES = (('mock', 'Mock Cluster'), 108 ('coreos', 'CoreOS Cluster'), 109 ('faulty', 'Faulty Cluster')) 110 111 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 112 id = models.CharField(max_length=128, unique=True) 113 type = models.CharField(max_length=16, choices=CLUSTER_TYPES, default='coreos') 114 115 domain = models.CharField(max_length=128, validators=[validate_domain]) 116 hosts = models.CharField(max_length=256, validators=[validate_comma_separated]) 117 auth = models.TextField() 118 options = JSONField(default={}, blank=True) 119 120 def __str__(self): 121 return self.id 122 123 def _get_scheduler(self, *args, **kwargs): 124 module_name = 'scheduler.' + self.type 125 mod = importlib.import_module(module_name) 126 return mod.SchedulerClient(self.id, self.hosts, self.auth, 127 self.domain, self.options) 128 129 _scheduler = property(_get_scheduler) 130 131 def create(self): 132 """ 133 Initialize a cluster's router and log aggregator 134 """ 135 return tasks.create_cluster.delay(self).get() 136 137 def destroy(self): 138 """ 139 Destroy a cluster's router and log aggregator 140 """ 141 return tasks.destroy_cluster.delay(self).get() 142 143 144 @python_2_unicode_compatible 145 class App(UuidAuditedModel): 146 """ 147 Application used to service requests on behalf of end-users 148 """ 149 150 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 151 id = models.SlugField(max_length=64, unique=True) 152 cluster = models.ForeignKey('Cluster') 153 structure = JSONField(default={}, blank=True, validators=[validate_app_structure]) 154 155 class Meta: 156 permissions = (('use_app', 'Can use app'),) 157 158 def __str__(self): 159 return self.id 160 161 @property 162 def url(self): 163 return self.id + '.' + self.cluster.domain 164 165 def create(self, *args, **kwargs): 166 config = Config.objects.create(owner=self.owner, app=self) 167 build = Build.objects.create(owner=self.owner, app=self, image=settings.DEFAULT_BUILD) 168 Release.objects.create(version=1, owner=self.owner, app=self, config=config, build=build) 169 170 def delete(self, *args, **kwargs): 171 for c in self.container_set.all(): 172 c.destroy() 173 # delete application logs stored by deis/logger 174 path = os.path.join(settings.DEIS_LOG_DIR, self.id + '.log') 175 if os.path.exists(path): 176 os.remove(path) 177 return super(App, self).delete(*args, **kwargs) 178 179 def deploy(self, release, initial=False): 180 tasks.deploy_release.delay(self, release).get() 181 if initial: 182 # if there is no SHA, assume a docker image is being promoted 183 if not release.build.sha: 184 self.structure = {'cmd': 1} 185 # if a dockerfile exists without a procfile, assume docker workflow 186 elif release.build.dockerfile and not release.build.procfile: 187 self.structure = {'cmd': 1} 188 # if a procfile exists without a web entry, assume docker workflow 189 elif release.build.procfile and 'web' not in release.build.procfile: 190 self.structure = {'cmd': 1} 191 # default to heroku workflow 192 else: 193 self.structure = {'web': 1} 194 self.save() 195 self.scale() 196 197 def destroy(self, *args, **kwargs): 198 return self.delete(*args, **kwargs) 199 200 def scale(self, **kwargs): # noqa 201 """Scale containers up or down to match requested.""" 202 requested_containers = self.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_containers.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 = 'Containers scaled ' + ' '.join( 213 "{}={}".format(k, v) for k, v in requested_containers.items()) 214 # iterate and scale by container type (web, worker, etc) 215 changed = False 216 to_add, to_remove = [], [] 217 for container_type in requested_containers.keys(): 218 containers = list(self.container_set.filter(type=container_type).order_by('created')) 219 # increment new container nums off the most recent container 220 results = self.container_set.filter(type=container_type).aggregate(Max('num')) 221 container_num = (results.get('num__max') or 0) + 1 222 requested = requested_containers.pop(container_type) 223 diff = requested - len(containers) 224 if diff == 0: 225 continue 226 changed = True 227 while diff < 0: 228 c = containers.pop() 229 to_remove.append(c) 230 diff += 1 231 while diff > 0: 232 c = Container.objects.create(owner=self.owner, 233 app=self, 234 release=release, 235 type=container_type, 236 num=container_num) 237 to_add.append(c) 238 container_num += 1 239 diff -= 1 240 if changed: 241 subtasks = [] 242 if to_add: 243 subtasks.append(tasks.start_containers.s(to_add)) 244 if to_remove: 245 subtasks.append(tasks.stop_containers.s(to_remove)) 246 group(*subtasks).apply_async().join() 247 log_event(self, msg) 248 return changed 249 250 def logs(self): 251 """Return aggregated log data for this application.""" 252 path = os.path.join(settings.DEIS_LOG_DIR, self.id + '.log') 253 if not os.path.exists(path): 254 raise EnvironmentError('Could not locate logs') 255 data = subprocess.check_output(['tail', '-n', str(settings.LOG_LINES), path]) 256 return data 257 258 def run(self, command): 259 """Run a one-off command in an ephemeral app container.""" 260 # TODO: add support for interactive shell 261 log_event(self, "deis run '{}'".format(command)) 262 c_num = max([c.num for c in self.container_set.filter(type='admin')] or [0]) + 1 263 c = Container.objects.create(owner=self.owner, 264 app=self, 265 release=self.release_set.latest(), 266 type='admin', 267 num=c_num) 268 rc, output = tasks.run_command.delay(c, command).get() 269 return rc, output 270 271 272 @python_2_unicode_compatible 273 class Container(UuidAuditedModel): 274 """ 275 Docker container used to securely host an application process. 276 """ 277 INITIALIZED = 'initialized' 278 CREATED = 'created' 279 UP = 'up' 280 DOWN = 'down' 281 DESTROYED = 'destroyed' 282 STATE_CHOICES = ( 283 (INITIALIZED, 'initialized'), 284 (CREATED, 'created'), 285 (UP, 'up'), 286 (DOWN, 'down'), 287 (DESTROYED, 'destroyed') 288 ) 289 290 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 291 app = models.ForeignKey('App') 292 release = models.ForeignKey('Release') 293 type = models.CharField(max_length=128, blank=False) 294 num = models.PositiveIntegerField() 295 state = FSMField(default=INITIALIZED, choices=STATE_CHOICES, protected=True) 296 297 def short_name(self): 298 return "{}.{}.{}".format(self.release.app.id, self.type, self.num) 299 short_name.short_description = 'Name' 300 301 def __str__(self): 302 return self.short_name() 303 304 class Meta: 305 get_latest_by = '-created' 306 ordering = ['created'] 307 308 def _get_job_id(self): 309 app = self.app.id 310 release = self.release 311 version = "v{}".format(release.version) 312 num = self.num 313 job_id = "{app}_{version}.{self.type}.{num}".format(**locals()) 314 return job_id 315 316 _job_id = property(_get_job_id) 317 318 def _get_scheduler(self): 319 return self.app.cluster._scheduler 320 321 _scheduler = property(_get_scheduler) 322 323 def _get_command(self): 324 # handle special case for Dockerfile deployments 325 if self.type == 'cmd': 326 return '' 327 else: 328 return 'start {}'.format(self.type) 329 330 _command = property(_get_command) 331 332 def _command_announceable(self): 333 return self._command.lower() in ['start web', ''] 334 335 @close_db_connections 336 @transition(field=state, source=INITIALIZED, target=CREATED) 337 def create(self): 338 image = self.release.image 339 kwargs = {'memory': self.release.config.memory, 340 'cpu': self.release.config.cpu, 341 'tags': self.release.config.tags} 342 self._scheduler.create(name=self._job_id, 343 image=image, 344 command=self._command, 345 use_announcer=self._command_announceable(), 346 **kwargs) 347 348 @close_db_connections 349 @transition(field=state, 350 source=[CREATED, UP, DOWN], 351 target=UP, crashed=DOWN) 352 def start(self): 353 self._scheduler.start(self._job_id, self._command_announceable()) 354 355 @close_db_connections 356 @transition(field=state, 357 source=[INITIALIZED, CREATED, UP, DOWN], 358 target=UP, 359 crashed=DOWN) 360 def deploy(self, release): 361 old_job_id = self._job_id 362 # update release 363 self.release = release 364 self.save() 365 # deploy new container 366 new_job_id = self._job_id 367 image = self.release.image 368 c_type = self.type 369 kwargs = {'memory': self.release.config.memory, 370 'cpu': self.release.config.cpu, 371 'tags': self.release.config.tags} 372 self._scheduler.create(name=new_job_id, 373 image=image, 374 command=self._command.format(**locals()), 375 use_announcer=self._command_announceable(), 376 **kwargs) 377 self._scheduler.start(new_job_id, self._command_announceable()) 378 # destroy old container 379 self._scheduler.destroy(old_job_id, self._command_announceable()) 380 381 @close_db_connections 382 @transition(field=state, source=UP, target=DOWN) 383 def stop(self): 384 self._scheduler.stop(self._job_id, self._command_announceable()) 385 386 @close_db_connections 387 @transition(field=state, 388 source=[INITIALIZED, CREATED, UP, DOWN], 389 target=DESTROYED) 390 def destroy(self): 391 # TODO: add check for active connections before killing 392 self._scheduler.destroy(self._job_id, self._command_announceable()) 393 394 @transition(field=state, 395 source=[INITIALIZED, CREATED, DESTROYED], 396 target=DESTROYED) 397 def run(self, command): 398 """Run a one-off command""" 399 rc, output = self._scheduler.run(self._job_id, self.release.image, command) 400 return rc, output 401 402 403 @python_2_unicode_compatible 404 class Push(UuidAuditedModel): 405 """ 406 Instance of a push used to trigger an application build 407 """ 408 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 409 app = models.ForeignKey('App') 410 sha = models.CharField(max_length=40) 411 412 fingerprint = models.CharField(max_length=255) 413 receive_user = models.CharField(max_length=255) 414 receive_repo = models.CharField(max_length=255) 415 416 ssh_connection = models.CharField(max_length=255) 417 ssh_original_command = models.CharField(max_length=255) 418 419 class Meta: 420 get_latest_by = 'created' 421 ordering = ['-created'] 422 unique_together = (('app', 'uuid'),) 423 424 def __str__(self): 425 return "{0}-{1}".format(self.app.id, self.sha[:7]) 426 427 428 @python_2_unicode_compatible 429 class Build(UuidAuditedModel): 430 """ 431 Instance of a software build used by runtime nodes 432 """ 433 434 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 435 app = models.ForeignKey('App') 436 image = models.CharField(max_length=256) 437 438 # optional fields populated by builder 439 sha = models.CharField(max_length=40, blank=True) 440 procfile = JSONField(default={}, blank=True) 441 dockerfile = models.TextField(blank=True) 442 443 class Meta: 444 get_latest_by = 'created' 445 ordering = ['-created'] 446 unique_together = (('app', 'uuid'),) 447 448 def __str__(self): 449 return "{0}-{1}".format(self.app.id, self.uuid[:7]) 450 451 452 @python_2_unicode_compatible 453 class Config(UuidAuditedModel): 454 """ 455 Set of configuration values applied as environment variables 456 during runtime execution of the Application. 457 """ 458 459 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 460 app = models.ForeignKey('App') 461 values = JSONField(default={}, blank=True) 462 memory = JSONField(default={}, blank=True) 463 cpu = JSONField(default={}, blank=True) 464 tags = JSONField(default={}, blank=True) 465 466 class Meta: 467 get_latest_by = 'created' 468 ordering = ['-created'] 469 unique_together = (('app', 'uuid'),) 470 471 def __str__(self): 472 return "{}-{}".format(self.app.id, self.uuid[:7]) 473 474 475 @python_2_unicode_compatible 476 class Release(UuidAuditedModel): 477 """ 478 Software release deployed by the application platform 479 480 Releases contain a :class:`Build` and a :class:`Config`. 481 """ 482 483 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 484 app = models.ForeignKey('App') 485 version = models.PositiveIntegerField() 486 summary = models.TextField(blank=True, null=True) 487 488 config = models.ForeignKey('Config') 489 build = models.ForeignKey('Build') 490 # NOTE: image contains combined build + config, ready to run 491 image = models.CharField(max_length=256, default=settings.DEFAULT_BUILD) 492 493 class Meta: 494 get_latest_by = 'created' 495 ordering = ['-created'] 496 unique_together = (('app', 'version'),) 497 498 def __str__(self): 499 return "{0}-v{1}".format(self.app.id, self.version) 500 501 def new(self, user, config=None, build=None, summary=None, source_version='latest'): 502 """ 503 Create a new application release using the provided Build and Config 504 on behalf of a user. 505 506 Releases start at v1 and auto-increment. 507 """ 508 if not config: 509 config = self.config 510 if not build: 511 build = self.build 512 # always create a release off the latest image 513 source_image = '{}:{}'.format(build.image, source_version) 514 # construct fully-qualified target image 515 new_version = self.version + 1 516 tag = 'v{}'.format(new_version) 517 release_image = '{}:{}'.format(self.app.id, tag) 518 target_image = '{}'.format(self.app.id) 519 # create new release and auto-increment version 520 release = Release.objects.create( 521 owner=user, app=self.app, config=config, 522 build=build, version=new_version, image=target_image, summary=summary) 523 # IOW, this image did not come from the builder 524 if not build.sha: 525 # we assume that the image is not present on our registry, 526 # so shell out a task to pull in the repository 527 tasks.import_repository.delay(build.image, self.app.id).get() 528 # update the source image to the repository we just imported 529 source_image = self.app.id 530 # if the image imported had a tag specified, use that tag as the source 531 if ':' in build.image: 532 if '/' not in build.image[build.image.rfind(':') + 1:]: 533 source_image += build.image[build.image.rfind(':'):] 534 535 publish_release(source_image, 536 config.values, 537 release_image,) 538 return release 539 540 def previous(self): 541 """ 542 Return the previous Release to this one. 543 544 :return: the previous :class:`Release`, or None 545 """ 546 releases = self.app.release_set 547 if self.pk: 548 releases = releases.exclude(pk=self.pk) 549 try: 550 # Get the Release previous to this one 551 prev_release = releases.latest() 552 except Release.DoesNotExist: 553 prev_release = None 554 return prev_release 555 556 def save(self, *args, **kwargs): # noqa 557 if not self.summary: 558 self.summary = '' 559 prev_release = self.previous() 560 # compare this build to the previous build 561 old_build = prev_release.build if prev_release else None 562 old_config = prev_release.config if prev_release else None 563 # if the build changed, log it and who pushed it 564 if self.version == 1: 565 self.summary += "{} created initial release".format(self.app.owner) 566 elif self.build != old_build: 567 if self.build.sha: 568 self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7]) 569 else: 570 self.summary += "{} deployed {}".format(self.build.owner, self.build.image) 571 # if the config data changed, log the dict diff 572 if self.config != old_config: 573 dict1 = self.config.values 574 dict2 = old_config.values if old_config else {} 575 diff = dict_diff(dict1, dict2) 576 # try to be as succinct as possible 577 added = ', '.join(k for k in diff.get('added', {})) 578 added = 'added ' + added if added else '' 579 changed = ', '.join(k for k in diff.get('changed', {})) 580 changed = 'changed ' + changed if changed else '' 581 deleted = ', '.join(k for k in diff.get('deleted', {})) 582 deleted = 'deleted ' + deleted if deleted else '' 583 changes = ', '.join(i for i in (added, changed, deleted) if i) 584 if changes: 585 if self.summary: 586 self.summary += ' and ' 587 self.summary += "{} {}".format(self.config.owner, changes) 588 # if the limits changed (memory or cpu), log the dict diff 589 changes = [] 590 old_mem = old_config.memory if old_config else {} 591 diff = dict_diff(self.config.memory, old_mem) 592 if diff.get('added') or diff.get('changed') or diff.get('deleted'): 593 changes.append('memory') 594 old_cpu = old_config.cpu if old_config else {} 595 diff = dict_diff(self.config.cpu, old_cpu) 596 if diff.get('added') or diff.get('changed') or diff.get('deleted'): 597 changes.append('cpu') 598 if changes: 599 changes = 'changed limits for '+', '.join(changes) 600 self.summary += "{} {}".format(self.config.owner, changes) 601 # if the tags changed, log the dict diff 602 changes = [] 603 old_tags = old_config.tags if old_config else {} 604 diff = dict_diff(self.config.tags, old_tags) 605 # try to be as succinct as possible 606 added = ', '.join(k for k in diff.get('added', {})) 607 added = 'added tag ' + added if added else '' 608 changed = ', '.join(k for k in diff.get('changed', {})) 609 changed = 'changed tag ' + changed if changed else '' 610 deleted = ', '.join(k for k in diff.get('deleted', {})) 611 deleted = 'deleted tag ' + deleted if deleted else '' 612 changes = ', '.join(i for i in (added, changed, deleted) if i) 613 if changes: 614 if self.summary: 615 self.summary += ' and ' 616 self.summary += "{} {}".format(self.config.owner, changes) 617 if not self.summary: 618 if self.version == 1: 619 self.summary = "{} created the initial release".format(self.owner) 620 else: 621 self.summary = "{} changed nothing".format(self.owner) 622 super(Release, self).save(*args, **kwargs) 623 624 625 @python_2_unicode_compatible 626 class Domain(AuditedModel): 627 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 628 app = models.ForeignKey('App') 629 domain = models.TextField(blank=False, null=False, unique=True) 630 631 def __str__(self): 632 return self.domain 633 634 635 @python_2_unicode_compatible 636 class Key(UuidAuditedModel): 637 """An SSH public key.""" 638 639 owner = models.ForeignKey(settings.AUTH_USER_MODEL) 640 id = models.CharField(max_length=128) 641 public = models.TextField(unique=True) 642 643 class Meta: 644 verbose_name = 'SSH Key' 645 unique_together = (('owner', 'id')) 646 647 def __str__(self): 648 return "{}...{}".format(self.public[:18], self.public[-31:]) 649 650 651 # define update/delete callbacks for synchronizing 652 # models with the configuration management backend 653 654 def _log_build_created(**kwargs): 655 if kwargs.get('created'): 656 build = kwargs['instance'] 657 log_event(build.app, "Build {} created".format(build)) 658 659 660 def _log_release_created(**kwargs): 661 if kwargs.get('created'): 662 release = kwargs['instance'] 663 log_event(release.app, "Release {} created".format(release)) 664 665 666 def _log_config_updated(**kwargs): 667 config = kwargs['instance'] 668 log_event(config.app, "Config {} updated".format(config)) 669 670 671 def _log_domain_added(**kwargs): 672 domain = kwargs['instance'] 673 log_event(domain.app, "Domain {} added".format(domain)) 674 675 676 def _log_domain_removed(**kwargs): 677 domain = kwargs['instance'] 678 log_event(domain.app, "Domain {} removed".format(domain)) 679 680 681 def _etcd_publish_key(**kwargs): 682 key = kwargs['instance'] 683 _etcd_client.write('/deis/builder/users/{}/{}'.format( 684 key.owner.username, fingerprint(key.public)), key.public) 685 686 687 def _etcd_purge_key(**kwargs): 688 key = kwargs['instance'] 689 _etcd_client.delete('/deis/builder/users/{}/{}'.format( 690 key.owner.username, fingerprint(key.public))) 691 692 693 def _etcd_purge_user(**kwargs): 694 username = kwargs['instance'].username 695 try: 696 _etcd_client.delete( 697 '/deis/builder/users/{}'.format(username), dir=True, recursive=True) 698 except KeyError: 699 # If _etcd_publish_key() wasn't called, there is no user dir to delete. 700 pass 701 702 703 def _etcd_create_app(**kwargs): 704 appname = kwargs['instance'] 705 if kwargs['created']: 706 _etcd_client.write('/deis/services/{}'.format(appname), None, dir=True) 707 708 709 def _etcd_purge_app(**kwargs): 710 appname = kwargs['instance'] 711 _etcd_client.delete('/deis/services/{}'.format(appname), dir=True, recursive=True) 712 713 714 def _etcd_publish_domains(**kwargs): 715 app = kwargs['instance'].app 716 app_domains = app.domain_set.all() 717 if app_domains: 718 _etcd_client.write('/deis/domains/{}'.format(app), 719 ' '.join(str(d.domain) for d in app_domains)) 720 else: 721 _etcd_client.delete('/deis/domains/{}'.format(app)) 722 723 724 # Log significant app-related events 725 post_save.connect(_log_build_created, sender=Build, dispatch_uid='api.models.log') 726 post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log') 727 post_save.connect(_log_config_updated, sender=Config, dispatch_uid='api.models.log') 728 post_save.connect(_log_domain_added, sender=Domain, dispatch_uid='api.models.log') 729 post_delete.connect(_log_domain_removed, sender=Domain, dispatch_uid='api.models.log') 730 731 732 # save FSM transitions as they happen 733 def _save_transition(**kwargs): 734 kwargs['instance'].save() 735 736 post_transition.connect(_save_transition) 737 738 # wire up etcd publishing if we can connect 739 try: 740 _etcd_client = etcd.Client(host=settings.ETCD_HOST, port=int(settings.ETCD_PORT)) 741 _etcd_client.get('/deis') 742 except etcd.EtcdException: 743 logger.log(logging.WARNING, 'Cannot synchronize with etcd cluster') 744 _etcd_client = None 745 746 if _etcd_client: 747 post_save.connect(_etcd_publish_key, sender=Key, dispatch_uid='api.models') 748 post_delete.connect(_etcd_purge_key, sender=Key, dispatch_uid='api.models') 749 post_delete.connect(_etcd_purge_user, sender=User, dispatch_uid='api.models') 750 post_save.connect(_etcd_publish_domains, sender=Domain, dispatch_uid='api.models') 751 post_delete.connect(_etcd_publish_domains, sender=Domain, dispatch_uid='api.models') 752 post_save.connect(_etcd_create_app, sender=App, dispatch_uid='api.models') 753 post_delete.connect(_etcd_purge_app, sender=App, dispatch_uid='api.models')