github.com/tmlbl/deis@v1.0.2/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')