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')