github.com/rvaralda/deis@v1.4.1/controller/api/models.py (about)

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