github.com/dustinrc/deis@v1.10.1-0.20150917223407-0894a5fb979e/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  from datetime import datetime
    10  import etcd
    11  import importlib
    12  import logging
    13  import os
    14  import re
    15  import subprocess
    16  import time
    17  from threading import Thread
    18  
    19  from django.conf import settings
    20  from django.contrib.auth import get_user_model
    21  from django.core.exceptions import ValidationError, SuspiciousOperation
    22  from django.db import models
    23  from django.db.models import Count
    24  from django.db.models import Max
    25  from django.db.models.signals import post_delete, post_save
    26  from django.dispatch import receiver
    27  from django.utils.encoding import python_2_unicode_compatible
    28  from docker.utils import utils as dockerutils
    29  from json_field.fields import JSONField
    30  from OpenSSL import crypto
    31  import requests
    32  from rest_framework.authtoken.models import Token
    33  
    34  from api import fields, utils, exceptions
    35  from registry import publish_release
    36  from utils import dict_diff, fingerprint
    37  
    38  
    39  logger = logging.getLogger(__name__)
    40  
    41  
    42  def close_db_connections(func, *args, **kwargs):
    43      """
    44      Decorator to explicitly close db connections during threaded execution
    45  
    46      Note this is necessary to work around:
    47      https://code.djangoproject.com/ticket/22420
    48      """
    49      def _close_db_connections(*args, **kwargs):
    50          ret = None
    51          try:
    52              ret = func(*args, **kwargs)
    53          finally:
    54              from django.db import connections
    55              for conn in connections.all():
    56                  conn.close()
    57          return ret
    58      return _close_db_connections
    59  
    60  
    61  def log_event(app, msg, level=logging.INFO):
    62      # controller needs to know which app this log comes from
    63      logger.log(level, "{}: {}".format(app.id, msg))
    64      app.log(msg, level)
    65  
    66  
    67  def validate_base64(value):
    68      """Check that value contains only valid base64 characters."""
    69      try:
    70          base64.b64decode(value.split()[1])
    71      except Exception as e:
    72          raise ValidationError(e)
    73  
    74  
    75  def validate_id_is_docker_compatible(value):
    76      """
    77      Check that the ID follows docker's image name constraints
    78      """
    79      match = re.match(r'^[a-z0-9-]+$', value)
    80      if not match:
    81          raise ValidationError("App IDs can only contain [a-z0-9-].")
    82  
    83  
    84  def validate_app_structure(value):
    85      """Error if the dict values aren't ints >= 0."""
    86      try:
    87          if any(int(v) < 0 for v in value.viewvalues()):
    88              raise ValueError("Must be greater than or equal to zero")
    89      except ValueError, err:
    90          raise ValidationError(err)
    91  
    92  
    93  def validate_reserved_names(value):
    94      """A value cannot use some reserved names."""
    95      if value in settings.DEIS_RESERVED_NAMES:
    96          raise ValidationError('{} is a reserved name.'.format(value))
    97  
    98  
    99  def validate_comma_separated(value):
   100      """Error if the value doesn't look like a list of hostnames or IP addresses
   101      separated by commas.
   102      """
   103      if not re.search(r'^[a-zA-Z0-9-,\.]+$', value):
   104          raise ValidationError(
   105              "{} should be a comma-separated list".format(value))
   106  
   107  
   108  def validate_domain(value):
   109      """Error if the domain contains unexpected characters."""
   110      if not re.search(r'^[a-zA-Z0-9-\.]+$', value):
   111          raise ValidationError('"{}" contains unexpected characters'.format(value))
   112  
   113  
   114  def validate_certificate(value):
   115      try:
   116          crypto.load_certificate(crypto.FILETYPE_PEM, value)
   117      except crypto.Error as e:
   118          raise ValidationError('Could not load certificate: {}'.format(e))
   119  
   120  
   121  def get_etcd_client():
   122      if not hasattr(get_etcd_client, "client"):
   123          # wire up etcd publishing if we can connect
   124          try:
   125              get_etcd_client.client = etcd.Client(
   126                  host=settings.ETCD_HOST,
   127                  port=int(settings.ETCD_PORT))
   128              get_etcd_client.client.get('/deis')
   129          except etcd.EtcdException:
   130              logger.log(logging.WARNING, 'Cannot synchronize with etcd cluster')
   131              get_etcd_client.client = None
   132      return get_etcd_client.client
   133  
   134  
   135  class AuditedModel(models.Model):
   136      """Add created and updated fields to a model."""
   137  
   138      created = models.DateTimeField(auto_now_add=True)
   139      updated = models.DateTimeField(auto_now=True)
   140  
   141      class Meta:
   142          """Mark :class:`AuditedModel` as abstract."""
   143          abstract = True
   144  
   145  
   146  def select_app_name():
   147      """Select a unique randomly generated app name"""
   148      name = utils.generate_app_name()
   149  
   150      while App.objects.filter(id=name).exists():
   151          name = utils.generate_app_name()
   152  
   153      return name
   154  
   155  
   156  class UuidAuditedModel(AuditedModel):
   157      """Add a UUID primary key to an :class:`AuditedModel`."""
   158  
   159      uuid = fields.UuidField('UUID', primary_key=True)
   160  
   161      class Meta:
   162          """Mark :class:`UuidAuditedModel` as abstract."""
   163          abstract = True
   164  
   165  
   166  @python_2_unicode_compatible
   167  class App(UuidAuditedModel):
   168      """
   169      Application used to service requests on behalf of end-users
   170      """
   171  
   172      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   173      id = models.SlugField(max_length=64, unique=True, default=select_app_name,
   174                            validators=[validate_id_is_docker_compatible,
   175                                        validate_reserved_names])
   176      structure = JSONField(default={}, blank=True, validators=[validate_app_structure])
   177  
   178      class Meta:
   179          permissions = (('use_app', 'Can use app'),)
   180  
   181      @property
   182      def _scheduler(self):
   183          mod = importlib.import_module(settings.SCHEDULER_MODULE)
   184          return mod.SchedulerClient(settings.SCHEDULER_TARGET,
   185                                     settings.SCHEDULER_AUTH,
   186                                     settings.SCHEDULER_OPTIONS,
   187                                     settings.SSH_PRIVATE_KEY)
   188  
   189      def __str__(self):
   190          return self.id
   191  
   192      @property
   193      def url(self):
   194          return self.id + '.' + settings.DEIS_DOMAIN
   195  
   196      def _get_job_id(self, container_type):
   197          app = self.id
   198          release = self.release_set.latest()
   199          version = "v{}".format(release.version)
   200          job_id = "{app}_{version}.{container_type}".format(**locals())
   201          return job_id
   202  
   203      def _get_command(self, container_type):
   204          try:
   205              # if this is not procfile-based app, ensure they cannot break out
   206              # and run arbitrary commands on the host
   207              # FIXME: remove slugrunner's hardcoded entrypoint
   208              release = self.release_set.latest()
   209              if release.build.dockerfile or not release.build.sha:
   210                  return "bash -c '{}'".format(release.build.procfile[container_type])
   211              else:
   212                  return 'start {}'.format(container_type)
   213          # if the key is not present or if a parent attribute is None
   214          except (KeyError, TypeError, AttributeError):
   215              # handle special case for Dockerfile deployments
   216              return '' if container_type == 'cmd' else 'start {}'.format(container_type)
   217  
   218      def log(self, message, level=logging.INFO):
   219          """Logs a message in the context of this application.
   220  
   221          This prefixes log messages with an application "tag" that the customized deis-logspout will
   222          be on the lookout for.  When it's seen, the message-- usually an application event of some
   223          sort like releasing or scaling, will be considered as "belonging" to the application
   224          instead of the controller and will be handled accordingly.
   225          """
   226          logger.log(level, "[{}]: {}".format(self.id, message))
   227  
   228      def create(self, *args, **kwargs):
   229          """Create a new application with an initial config and release"""
   230          config = Config.objects.create(owner=self.owner, app=self)
   231          Release.objects.create(version=1, owner=self.owner, app=self, config=config, build=None)
   232  
   233      def delete(self, *args, **kwargs):
   234          """Delete this application including all containers"""
   235          try:
   236              # attempt to remove containers from the scheduler
   237              self._destroy_containers([c for c in self.container_set.exclude(type='run')])
   238          except RuntimeError:
   239              pass
   240          self._clean_app_logs()
   241          return super(App, self).delete(*args, **kwargs)
   242  
   243      def restart(self, **kwargs):
   244          to_restart = self.container_set.all()
   245          if kwargs.get('type'):
   246              to_restart = to_restart.filter(type=kwargs.get('type'))
   247          if kwargs.get('num'):
   248              to_restart = to_restart.filter(num=kwargs.get('num'))
   249          self._restart_containers(to_restart)
   250          return to_restart
   251  
   252      def _clean_app_logs(self):
   253          """Delete application logs stored by the logger component"""
   254          path = os.path.join(settings.DEIS_LOG_DIR, self.id + '.log')
   255          if os.path.exists(path):
   256              os.remove(path)
   257  
   258      def scale(self, user, structure):  # noqa
   259          """Scale containers up or down to match requested structure."""
   260          if self.release_set.latest().build is None:
   261              raise EnvironmentError('No build associated with this release')
   262          requested_structure = structure.copy()
   263          release = self.release_set.latest()
   264          # test for available process types
   265          available_process_types = release.build.procfile or {}
   266          for container_type in requested_structure:
   267              if container_type == 'cmd':
   268                  continue  # allow docker cmd types in case we don't have the image source
   269              if container_type not in available_process_types:
   270                  raise EnvironmentError(
   271                      'Container type {} does not exist in application'.format(container_type))
   272          msg = '{} scaled containers '.format(user.username) + ' '.join(
   273              "{}={}".format(k, v) for k, v in requested_structure.items())
   274          log_event(self, msg)
   275          # iterate and scale by container type (web, worker, etc)
   276          changed = False
   277          to_add, to_remove = [], []
   278          scale_types = {}
   279  
   280          # iterate on a copy of the container_type keys
   281          for container_type in requested_structure.keys():
   282              containers = list(self.container_set.filter(type=container_type).order_by('created'))
   283              # increment new container nums off the most recent container
   284              results = self.container_set.filter(type=container_type).aggregate(Max('num'))
   285              container_num = (results.get('num__max') or 0) + 1
   286              requested = requested_structure.pop(container_type)
   287              diff = requested - len(containers)
   288              if diff == 0:
   289                  continue
   290              changed = True
   291              scale_types[container_type] = requested
   292              while diff < 0:
   293                  c = containers.pop()
   294                  to_remove.append(c)
   295                  diff += 1
   296              while diff > 0:
   297                  # create a database record
   298                  c = Container.objects.create(owner=self.owner,
   299                                               app=self,
   300                                               release=release,
   301                                               type=container_type,
   302                                               num=container_num)
   303                  to_add.append(c)
   304                  container_num += 1
   305                  diff -= 1
   306  
   307          if changed:
   308              if "scale" in dir(self._scheduler):
   309                  self._scale_containers(scale_types, to_remove)
   310              else:
   311                  if to_add:
   312                      self._start_containers(to_add)
   313                  if to_remove:
   314                      self._destroy_containers(to_remove)
   315          # save new structure to the database
   316          vals = self.container_set.exclude(type='run').values(
   317              'type').annotate(Count('pk')).order_by()
   318          new_structure = structure.copy()
   319          new_structure.update({v['type']: v['pk__count'] for v in vals})
   320          self.structure = new_structure
   321          self.save()
   322          return changed
   323  
   324      def _scale_containers(self, scale_types, to_remove):
   325          release = self.release_set.latest()
   326          for scale_type in scale_types:
   327              image = release.image
   328              version = "v{}".format(release.version)
   329              kwargs = {'memory': release.config.memory,
   330                        'cpu': release.config.cpu,
   331                        'tags': release.config.tags,
   332                        'version': version,
   333                        'aname': self.id,
   334                        'num': scale_types[scale_type]}
   335              job_id = self._get_job_id(scale_type)
   336              command = self._get_command(scale_type)
   337              try:
   338                  self._scheduler.scale(
   339                      name=job_id,
   340                      image=image,
   341                      command=command,
   342                      **kwargs)
   343              except Exception as e:
   344                  err = '{} (scale): {}'.format(job_id, e)
   345                  log_event(self, err, logging.ERROR)
   346                  raise
   347          [c.delete() for c in to_remove]
   348  
   349      def _start_containers(self, to_add):
   350          """Creates and starts containers via the scheduler"""
   351          if not to_add:
   352              return
   353          create_threads = [Thread(target=c.create) for c in to_add]
   354          start_threads = [Thread(target=c.start) for c in to_add]
   355          [t.start() for t in create_threads]
   356          [t.join() for t in create_threads]
   357          if any(c.state != 'created' for c in to_add):
   358              err = 'aborting, failed to create some containers'
   359              log_event(self, err, logging.ERROR)
   360              self._destroy_containers(to_add)
   361              raise RuntimeError(err)
   362          [t.start() for t in start_threads]
   363          [t.join() for t in start_threads]
   364          if set([c.state for c in to_add]) != set(['up']):
   365              err = 'warning, some containers failed to start'
   366              log_event(self, err, logging.WARNING)
   367          # if the user specified a health check, try checking to see if it's running
   368          try:
   369              config = self.config_set.latest()
   370              if 'HEALTHCHECK_URL' in config.values.keys():
   371                  self._healthcheck(to_add, config.values)
   372          except Config.DoesNotExist:
   373              pass
   374  
   375      def _healthcheck(self, containers, config):
   376          # if at first it fails, back off and try again at 10%, 50% and 100% of INITIAL_DELAY
   377          intervals = [1.0, 0.1, 0.5, 1.0]
   378          # HACK (bacongobbler): we need to wait until publisher has a chance to publish each
   379          # service to etcd, which can take up to 20 seconds.
   380          time.sleep(20)
   381          for i in xrange(len(intervals)):
   382              delay = int(config.get('HEALTHCHECK_INITIAL_DELAY', 0))
   383              try:
   384                  # sleep until the initial timeout is over
   385                  if delay > 0:
   386                      time.sleep(delay * intervals[i])
   387                  to_healthcheck = [c for c in containers if c.type in ['web', 'cmd']]
   388                  self._do_healthcheck(to_healthcheck, config)
   389                  break
   390              except exceptions.HealthcheckException as e:
   391                  try:
   392                      next_delay = delay * intervals[i+1]
   393                      msg = "{}; trying again in {} seconds".format(e, next_delay)
   394                      log_event(self, msg, logging.WARNING)
   395                  except IndexError:
   396                      log_event(self, e, logging.WARNING)
   397          else:
   398              self._destroy_containers(containers)
   399              msg = "aborting, app containers failed to respond to health check"
   400              log_event(self, msg, logging.ERROR)
   401              raise RuntimeError(msg)
   402  
   403      def _do_healthcheck(self, containers, config):
   404          path = config.get('HEALTHCHECK_URL', '/')
   405          timeout = int(config.get('HEALTHCHECK_TIMEOUT', 1))
   406          if not _etcd_client:
   407              raise exceptions.HealthcheckException('no etcd client available')
   408          for container in containers:
   409              try:
   410                  key = "/deis/services/{self}/{container.job_id}".format(**locals())
   411                  url = "http://{}{}".format(_etcd_client.get(key).value, path)
   412                  response = requests.get(url, timeout=timeout)
   413                  if response.status_code != requests.codes.OK:
   414                      raise exceptions.HealthcheckException(
   415                          "app failed health check (got '{}', expected: '200')".format(
   416                              response.status_code))
   417              except (requests.Timeout, requests.ConnectionError, KeyError) as e:
   418                  raise exceptions.HealthcheckException(
   419                      'failed to connect to container ({})'.format(e))
   420  
   421      def _restart_containers(self, to_restart):
   422          """Restarts containers via the scheduler"""
   423          if not to_restart:
   424              return
   425          stop_threads = [Thread(target=c.stop) for c in to_restart]
   426          start_threads = [Thread(target=c.start) for c in to_restart]
   427          [t.start() for t in stop_threads]
   428          [t.join() for t in stop_threads]
   429          if any(c.state != 'created' for c in to_restart):
   430              err = 'warning, some containers failed to stop'
   431              log_event(self, err, logging.WARNING)
   432          [t.start() for t in start_threads]
   433          [t.join() for t in start_threads]
   434          if any(c.state != 'up' for c in to_restart):
   435              err = 'warning, some containers failed to start'
   436              log_event(self, err, logging.WARNING)
   437  
   438      def _destroy_containers(self, to_destroy):
   439          """Destroys containers via the scheduler"""
   440          if not to_destroy:
   441              return
   442          destroy_threads = [Thread(target=c.destroy) for c in to_destroy]
   443          [t.start() for t in destroy_threads]
   444          [t.join() for t in destroy_threads]
   445          [c.delete() for c in to_destroy if c.state == 'destroyed']
   446          if any(c.state != 'destroyed' for c in to_destroy):
   447              err = 'aborting, failed to destroy some containers'
   448              log_event(self, err, logging.ERROR)
   449              raise RuntimeError(err)
   450  
   451      def deploy(self, user, release):
   452          """Deploy a new release to this application"""
   453          existing = self.container_set.exclude(type='run')
   454          new = []
   455          scale_types = set()
   456          for e in existing:
   457              n = e.clone(release)
   458              n.save()
   459              new.append(n)
   460              scale_types.add(e.type)
   461  
   462          if new and "deploy" in dir(self._scheduler):
   463              self._deploy_app(scale_types, release, existing)
   464          else:
   465              self._start_containers(new)
   466  
   467              # destroy old containers
   468              if existing:
   469                  self._destroy_containers(existing)
   470  
   471          # perform default scaling if necessary
   472          if self.structure == {} and release.build is not None:
   473              self._default_scale(user, release)
   474  
   475      def _deploy_app(self, scale_types, release, existing):
   476          for scale_type in scale_types:
   477              image = release.image
   478              version = "v{}".format(release.version)
   479              kwargs = {'memory': release.config.memory,
   480                        'cpu': release.config.cpu,
   481                        'tags': release.config.tags,
   482                        'aname': self.id,
   483                        'num': 0,
   484                        'version': version}
   485              job_id = self._get_job_id(scale_type)
   486              command = self._get_command(scale_type)
   487              try:
   488                  self._scheduler.deploy(
   489                      name=job_id,
   490                      image=image,
   491                      command=command,
   492                      **kwargs)
   493              except Exception as e:
   494                  err = '{} (deploy): {}'.format(job_id, e)
   495                  log_event(self, err, logging.ERROR)
   496                  raise
   497          [c.delete() for c in existing]
   498  
   499      def _default_scale(self, user, release):
   500          """Scale to default structure based on release type"""
   501          # if there is no SHA, assume a docker image is being promoted
   502          if not release.build.sha:
   503              structure = {'cmd': 1}
   504  
   505          # if a dockerfile exists without a procfile, assume docker workflow
   506          elif release.build.dockerfile and not release.build.procfile:
   507              structure = {'cmd': 1}
   508  
   509          # if a procfile exists without a web entry, assume docker workflow
   510          elif release.build.procfile and 'web' not in release.build.procfile:
   511              structure = {'cmd': 1}
   512  
   513          # default to heroku workflow
   514          else:
   515              structure = {'web': 1}
   516  
   517          self.scale(user, structure)
   518  
   519      def logs(self, log_lines=str(settings.LOG_LINES)):
   520          """Return aggregated log data for this application."""
   521          path = os.path.join(settings.DEIS_LOG_DIR, self.id + '.log')
   522          if not os.path.exists(path):
   523              raise EnvironmentError('Could not locate logs')
   524          data = subprocess.check_output(['tail', '-n', log_lines, path])
   525          return data
   526  
   527      def run(self, user, command):
   528          """Run a one-off command in an ephemeral app container."""
   529          # FIXME: remove the need for SSH private keys by using
   530          # a scheduler that supports one-off admin tasks natively
   531          if not settings.SSH_PRIVATE_KEY:
   532              raise EnvironmentError('Support for admin commands is not configured')
   533          if self.release_set.latest().build is None:
   534              raise EnvironmentError('No build associated with this release to run this command')
   535          # TODO: add support for interactive shell
   536          msg = "{} runs '{}'".format(user.username, command)
   537          log_event(self, msg)
   538          c_num = max([c.num for c in self.container_set.filter(type='run')] or [0]) + 1
   539  
   540          # create database record for run process
   541          c = Container.objects.create(owner=self.owner,
   542                                       app=self,
   543                                       release=self.release_set.latest(),
   544                                       type='run',
   545                                       num=c_num)
   546          image = c.release.image
   547  
   548          # check for backwards compatibility
   549          def _has_hostname(image):
   550              repo, tag = dockerutils.parse_repository_tag(image)
   551              return True if '/' in repo and '.' in repo.split('/')[0] else False
   552  
   553          if not _has_hostname(image):
   554              image = '{}:{}/{}'.format(settings.REGISTRY_HOST,
   555                                        settings.REGISTRY_PORT,
   556                                        image)
   557          # SECURITY: shell-escape user input
   558          escaped_command = command.replace("'", "'\\''")
   559          return c.run(escaped_command)
   560  
   561  
   562  @python_2_unicode_compatible
   563  class Container(UuidAuditedModel):
   564      """
   565      Docker container used to securely host an application process.
   566      """
   567  
   568      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   569      app = models.ForeignKey('App')
   570      release = models.ForeignKey('Release')
   571      type = models.CharField(max_length=128, blank=False)
   572      num = models.PositiveIntegerField()
   573  
   574      @property
   575      def _scheduler(self):
   576          return self.app._scheduler
   577  
   578      @property
   579      def state(self):
   580          return self._scheduler.state(self.job_id).name
   581  
   582      def short_name(self):
   583          return "{}.{}.{}".format(self.app.id, self.type, self.num)
   584      short_name.short_description = 'Name'
   585  
   586      def __str__(self):
   587          return self.short_name()
   588  
   589      class Meta:
   590          get_latest_by = '-created'
   591          ordering = ['created']
   592  
   593      @property
   594      def job_id(self):
   595          version = "v{}".format(self.release.version)
   596          return "{self.app.id}_{version}.{self.type}.{self.num}".format(**locals())
   597  
   598      def _get_command(self):
   599          try:
   600              # if this is not procfile-based app, ensure they cannot break out
   601              # and run arbitrary commands on the host
   602              # FIXME: remove slugrunner's hardcoded entrypoint
   603              if self.release.build.dockerfile or not self.release.build.sha:
   604                  return "bash -c '{}'".format(self.release.build.procfile[self.type])
   605              else:
   606                  return 'start {}'.format(self.type)
   607          # if the key is not present or if a parent attribute is None
   608          except (KeyError, TypeError, AttributeError):
   609              # handle special case for Dockerfile deployments
   610              return '' if self.type == 'cmd' else 'start {}'.format(self.type)
   611  
   612      _command = property(_get_command)
   613  
   614      def clone(self, release):
   615          c = Container.objects.create(owner=self.owner,
   616                                       app=self.app,
   617                                       release=release,
   618                                       type=self.type,
   619                                       num=self.num)
   620          return c
   621  
   622      @close_db_connections
   623      def create(self):
   624          image = self.release.image
   625          kwargs = {'memory': self.release.config.memory,
   626                    'cpu': self.release.config.cpu,
   627                    'tags': self.release.config.tags}
   628          try:
   629              self._scheduler.create(
   630                  name=self.job_id,
   631                  image=image,
   632                  command=self._command,
   633                  **kwargs)
   634          except Exception as e:
   635              err = '{} (create): {}'.format(self.job_id, e)
   636              log_event(self.app, err, logging.ERROR)
   637              raise
   638  
   639      @close_db_connections
   640      def start(self):
   641          try:
   642              self._scheduler.start(self.job_id)
   643          except Exception as e:
   644              err = '{} (start): {}'.format(self.job_id, e)
   645              log_event(self.app, err, logging.WARNING)
   646              raise
   647  
   648      @close_db_connections
   649      def stop(self):
   650          try:
   651              self._scheduler.stop(self.job_id)
   652          except Exception as e:
   653              err = '{} (stop): {}'.format(self.job_id, e)
   654              log_event(self.app, err, logging.ERROR)
   655              raise
   656  
   657      @close_db_connections
   658      def destroy(self):
   659          try:
   660              self._scheduler.destroy(self.job_id)
   661          except Exception as e:
   662              err = '{} (destroy): {}'.format(self.job_id, e)
   663              log_event(self.app, err, logging.ERROR)
   664              raise
   665  
   666      def run(self, command):
   667          """Run a one-off command"""
   668          if self.release.build is None:
   669              raise EnvironmentError('No build associated with this release '
   670                                     'to run this command')
   671          image = self.release.image
   672          entrypoint = '/bin/bash'
   673          # if this is a procfile-based app, switch the entrypoint to slugrunner's default
   674          # FIXME: remove slugrunner's hardcoded entrypoint
   675          if self.release.build.procfile and \
   676             self.release.build.sha and not \
   677             self.release.build.dockerfile:
   678              entrypoint = '/runner/init'
   679              command = "'{}'".format(command)
   680          else:
   681              command = "-c '{}'".format(command)
   682          try:
   683              rc, output = self._scheduler.run(self.job_id, image, entrypoint, command)
   684              return rc, output
   685          except Exception as e:
   686              err = '{} (run): {}'.format(self.job_id, e)
   687              log_event(self.app, err, logging.ERROR)
   688              raise
   689  
   690  
   691  @python_2_unicode_compatible
   692  class Push(UuidAuditedModel):
   693      """
   694      Instance of a push used to trigger an application build
   695      """
   696      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   697      app = models.ForeignKey('App')
   698      sha = models.CharField(max_length=40)
   699  
   700      fingerprint = models.CharField(max_length=255)
   701      receive_user = models.CharField(max_length=255)
   702      receive_repo = models.CharField(max_length=255)
   703  
   704      ssh_connection = models.CharField(max_length=255)
   705      ssh_original_command = models.CharField(max_length=255)
   706  
   707      class Meta:
   708          get_latest_by = 'created'
   709          ordering = ['-created']
   710          unique_together = (('app', 'uuid'),)
   711  
   712      def __str__(self):
   713          return "{0}-{1}".format(self.app.id, self.sha[:7])
   714  
   715  
   716  @python_2_unicode_compatible
   717  class Build(UuidAuditedModel):
   718      """
   719      Instance of a software build used by runtime nodes
   720      """
   721  
   722      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   723      app = models.ForeignKey('App')
   724      image = models.CharField(max_length=256)
   725  
   726      # optional fields populated by builder
   727      sha = models.CharField(max_length=40, blank=True)
   728      procfile = JSONField(default={}, blank=True)
   729      dockerfile = models.TextField(blank=True)
   730  
   731      class Meta:
   732          get_latest_by = 'created'
   733          ordering = ['-created']
   734          unique_together = (('app', 'uuid'),)
   735  
   736      def create(self, user, *args, **kwargs):
   737          latest_release = self.app.release_set.latest()
   738          source_version = 'latest'
   739          if self.sha:
   740              source_version = 'git-{}'.format(self.sha)
   741          new_release = latest_release.new(user,
   742                                           build=self,
   743                                           config=latest_release.config,
   744                                           source_version=source_version)
   745          try:
   746              self.app.deploy(user, new_release)
   747              return new_release
   748          except RuntimeError:
   749              new_release.delete()
   750              raise
   751  
   752      def save(self, **kwargs):
   753          try:
   754              previous_build = self.app.build_set.latest()
   755              to_destroy = []
   756              for proctype in previous_build.procfile:
   757                  if proctype not in self.procfile:
   758                      for c in self.app.container_set.filter(type=proctype):
   759                          to_destroy.append(c)
   760              self.app._destroy_containers(to_destroy)
   761          except Build.DoesNotExist:
   762              pass
   763          return super(Build, self).save(**kwargs)
   764  
   765      def __str__(self):
   766          return "{0}-{1}".format(self.app.id, self.uuid[:7])
   767  
   768  
   769  @python_2_unicode_compatible
   770  class Config(UuidAuditedModel):
   771      """
   772      Set of configuration values applied as environment variables
   773      during runtime execution of the Application.
   774      """
   775  
   776      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   777      app = models.ForeignKey('App')
   778      values = JSONField(default={}, blank=True)
   779      memory = JSONField(default={}, blank=True)
   780      cpu = JSONField(default={}, blank=True)
   781      tags = JSONField(default={}, blank=True)
   782  
   783      class Meta:
   784          get_latest_by = 'created'
   785          ordering = ['-created']
   786          unique_together = (('app', 'uuid'),)
   787  
   788      def __str__(self):
   789          return "{}-{}".format(self.app.id, self.uuid[:7])
   790  
   791      def save(self, **kwargs):
   792          """merge the old config with the new"""
   793          try:
   794              previous_config = self.app.config_set.latest()
   795              for attr in ['cpu', 'memory', 'tags', 'values']:
   796                  # Guard against migrations from older apps without fixes to
   797                  # JSONField encoding.
   798                  try:
   799                      data = getattr(previous_config, attr).copy()
   800                  except AttributeError:
   801                      data = {}
   802                  try:
   803                      new_data = getattr(self, attr).copy()
   804                  except AttributeError:
   805                      new_data = {}
   806                  data.update(new_data)
   807                  # remove config keys if we provided a null value
   808                  [data.pop(k) for k, v in new_data.viewitems() if v is None]
   809                  setattr(self, attr, data)
   810          except Config.DoesNotExist:
   811              pass
   812          return super(Config, self).save(**kwargs)
   813  
   814  
   815  @python_2_unicode_compatible
   816  class Release(UuidAuditedModel):
   817      """
   818      Software release deployed by the application platform
   819  
   820      Releases contain a :class:`Build` and a :class:`Config`.
   821      """
   822  
   823      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   824      app = models.ForeignKey('App')
   825      version = models.PositiveIntegerField()
   826      summary = models.TextField(blank=True, null=True)
   827  
   828      config = models.ForeignKey('Config')
   829      build = models.ForeignKey('Build', null=True)
   830  
   831      class Meta:
   832          get_latest_by = 'created'
   833          ordering = ['-created']
   834          unique_together = (('app', 'version'),)
   835  
   836      def __str__(self):
   837          return "{0}-v{1}".format(self.app.id, self.version)
   838  
   839      @property
   840      def image(self):
   841          return '{}:v{}'.format(self.app.id, str(self.version))
   842  
   843      def new(self, user, config, build, summary=None, source_version='latest'):
   844          """
   845          Create a new application release using the provided Build and Config
   846          on behalf of a user.
   847  
   848          Releases start at v1 and auto-increment.
   849          """
   850          # construct fully-qualified target image
   851          new_version = self.version + 1
   852          # create new release and auto-increment version
   853          release = Release.objects.create(
   854              owner=user, app=self.app, config=config,
   855              build=build, version=new_version, summary=summary)
   856          try:
   857              release.publish()
   858          except EnvironmentError as e:
   859              # If we cannot publish this app, just log and carry on
   860              log_event(self.app, e)
   861              pass
   862          return release
   863  
   864      def publish(self, source_version='latest'):
   865          if self.build is None:
   866              raise EnvironmentError('No build associated with this release to publish')
   867          source_tag = 'git-{}'.format(self.build.sha) if self.build.sha else source_version
   868          source_image = '{}:{}'.format(self.build.image, source_tag)
   869          # IOW, this image did not come from the builder
   870          # FIXME: remove check for mock registry module
   871          if not self.build.sha and 'mock' not in settings.REGISTRY_MODULE:
   872              # we assume that the image is not present on our registry,
   873              # so shell out a task to pull in the repository
   874              data = {
   875                  'src': self.build.image
   876              }
   877              requests.post(
   878                  '{}/v1/repositories/{}/tags'.format(settings.REGISTRY_URL,
   879                                                      self.app.id),
   880                  data=data,
   881              )
   882              # update the source image to the repository we just imported
   883              source_image = self.app.id
   884              # if the image imported had a tag specified, use that tag as the source
   885              if ':' in self.build.image:
   886                  if '/' not in self.build.image[self.build.image.rfind(':') + 1:]:
   887                      source_image += self.build.image[self.build.image.rfind(':'):]
   888          publish_release(source_image,
   889                          self.config.values,
   890                          self.image)
   891  
   892      def previous(self):
   893          """
   894          Return the previous Release to this one.
   895  
   896          :return: the previous :class:`Release`, or None
   897          """
   898          releases = self.app.release_set
   899          if self.pk:
   900              releases = releases.exclude(pk=self.pk)
   901          try:
   902              # Get the Release previous to this one
   903              prev_release = releases.latest()
   904          except Release.DoesNotExist:
   905              prev_release = None
   906          return prev_release
   907  
   908      def rollback(self, user, version):
   909          if version < 1:
   910              raise EnvironmentError('version cannot be below 0')
   911          summary = "{} rolled back to v{}".format(user, version)
   912          prev = self.app.release_set.get(version=version)
   913          new_release = self.new(
   914              user,
   915              build=prev.build,
   916              config=prev.config,
   917              summary=summary,
   918              source_version='v{}'.format(version))
   919          try:
   920              self.app.deploy(user, new_release)
   921              return new_release
   922          except RuntimeError:
   923              new_release.delete()
   924              raise
   925  
   926      def save(self, *args, **kwargs):  # noqa
   927          if not self.summary:
   928              self.summary = ''
   929              prev_release = self.previous()
   930              # compare this build to the previous build
   931              old_build = prev_release.build if prev_release else None
   932              old_config = prev_release.config if prev_release else None
   933              # if the build changed, log it and who pushed it
   934              if self.version == 1:
   935                  self.summary += "{} created initial release".format(self.app.owner)
   936              elif self.build != old_build:
   937                  if self.build.sha:
   938                      self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7])
   939                  else:
   940                      self.summary += "{} deployed {}".format(self.build.owner, self.build.image)
   941              # if the config data changed, log the dict diff
   942              if self.config != old_config:
   943                  dict1 = self.config.values
   944                  dict2 = old_config.values if old_config else {}
   945                  diff = dict_diff(dict1, dict2)
   946                  # try to be as succinct as possible
   947                  added = ', '.join(k for k in diff.get('added', {}))
   948                  added = 'added ' + added if added else ''
   949                  changed = ', '.join(k for k in diff.get('changed', {}))
   950                  changed = 'changed ' + changed if changed else ''
   951                  deleted = ', '.join(k for k in diff.get('deleted', {}))
   952                  deleted = 'deleted ' + deleted if deleted else ''
   953                  changes = ', '.join(i for i in (added, changed, deleted) if i)
   954                  if changes:
   955                      if self.summary:
   956                          self.summary += ' and '
   957                      self.summary += "{} {}".format(self.config.owner, changes)
   958                  # if the limits changed (memory or cpu), log the dict diff
   959                  changes = []
   960                  old_mem = old_config.memory if old_config else {}
   961                  diff = dict_diff(self.config.memory, old_mem)
   962                  if diff.get('added') or diff.get('changed') or diff.get('deleted'):
   963                      changes.append('memory')
   964                  old_cpu = old_config.cpu if old_config else {}
   965                  diff = dict_diff(self.config.cpu, old_cpu)
   966                  if diff.get('added') or diff.get('changed') or diff.get('deleted'):
   967                      changes.append('cpu')
   968                  if changes:
   969                      changes = 'changed limits for '+', '.join(changes)
   970                      self.summary += "{} {}".format(self.config.owner, changes)
   971                  # if the tags changed, log the dict diff
   972                  changes = []
   973                  old_tags = old_config.tags if old_config else {}
   974                  diff = dict_diff(self.config.tags, old_tags)
   975                  # try to be as succinct as possible
   976                  added = ', '.join(k for k in diff.get('added', {}))
   977                  added = 'added tag ' + added if added else ''
   978                  changed = ', '.join(k for k in diff.get('changed', {}))
   979                  changed = 'changed tag ' + changed if changed else ''
   980                  deleted = ', '.join(k for k in diff.get('deleted', {}))
   981                  deleted = 'deleted tag ' + deleted if deleted else ''
   982                  changes = ', '.join(i for i in (added, changed, deleted) if i)
   983                  if changes:
   984                      if self.summary:
   985                          self.summary += ' and '
   986                      self.summary += "{} {}".format(self.config.owner, changes)
   987              if not self.summary:
   988                  if self.version == 1:
   989                      self.summary = "{} created the initial release".format(self.owner)
   990                  else:
   991                      self.summary = "{} changed nothing".format(self.owner)
   992          super(Release, self).save(*args, **kwargs)
   993  
   994  
   995  @python_2_unicode_compatible
   996  class Domain(AuditedModel):
   997      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
   998      app = models.ForeignKey('App')
   999      domain = models.TextField(blank=False, null=False, unique=True)
  1000  
  1001      def __str__(self):
  1002          return self.domain
  1003  
  1004  
  1005  @python_2_unicode_compatible
  1006  class Certificate(AuditedModel):
  1007      """
  1008      Public and private key pair used to secure application traffic at the router.
  1009      """
  1010      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
  1011      # there is no upper limit on the size of an x.509 certificate
  1012      certificate = models.TextField(validators=[validate_certificate])
  1013      key = models.TextField()
  1014      # X.509 certificates allow any string of information as the common name.
  1015      common_name = models.TextField(unique=True)
  1016      expires = models.DateTimeField()
  1017  
  1018      def __str__(self):
  1019          return self.common_name
  1020  
  1021      def _get_certificate(self):
  1022          try:
  1023              return crypto.load_certificate(crypto.FILETYPE_PEM, self.certificate)
  1024          except crypto.Error as e:
  1025              raise SuspiciousOperation(e)
  1026  
  1027      def save(self, *args, **kwargs):
  1028          certificate = self._get_certificate()
  1029          if not self.common_name:
  1030              self.common_name = certificate.get_subject().CN
  1031          if not self.expires:
  1032              # convert openssl's expiry date format to Django's DateTimeField format
  1033              self.expires = datetime.strptime(certificate.get_notAfter(), '%Y%m%d%H%M%SZ')
  1034          return super(Certificate, self).save(*args, **kwargs)
  1035  
  1036  
  1037  @python_2_unicode_compatible
  1038  class Key(UuidAuditedModel):
  1039      """An SSH public key."""
  1040  
  1041      owner = models.ForeignKey(settings.AUTH_USER_MODEL)
  1042      id = models.CharField(max_length=128)
  1043      public = models.TextField(unique=True, validators=[validate_base64])
  1044      fingerprint = models.CharField(max_length=128)
  1045  
  1046      class Meta:
  1047          verbose_name = 'SSH Key'
  1048          unique_together = (('owner', 'fingerprint'))
  1049  
  1050      def __str__(self):
  1051          return "{}...{}".format(self.public[:18], self.public[-31:])
  1052  
  1053      def save(self, *args, **kwargs):
  1054          self.fingerprint = fingerprint(self.public)
  1055          return super(Key, self).save(*args, **kwargs)
  1056  
  1057  
  1058  # define update/delete callbacks for synchronizing
  1059  # models with the configuration management backend
  1060  
  1061  def _log_build_created(**kwargs):
  1062      if kwargs.get('created'):
  1063          build = kwargs['instance']
  1064          # log only to the controller; this event will be logged in the release summary
  1065          logger.info("{}: build {} created".format(build.app, build))
  1066  
  1067  
  1068  def _log_release_created(**kwargs):
  1069      if kwargs.get('created'):
  1070          release = kwargs['instance']
  1071          # log only to the controller; this event will be logged in the release summary
  1072          logger.info("{}: release {} created".format(release.app, release))
  1073          # append release lifecycle logs to the app
  1074          release.app.log(release.summary)
  1075  
  1076  
  1077  def _log_config_updated(**kwargs):
  1078      config = kwargs['instance']
  1079      # log only to the controller; this event will be logged in the release summary
  1080      logger.info("{}: config {} updated".format(config.app, config))
  1081  
  1082  
  1083  def _log_domain_added(**kwargs):
  1084      if kwargs.get('created'):
  1085          domain = kwargs['instance']
  1086          msg = "domain {} added".format(domain)
  1087          log_event(domain.app, msg)
  1088  
  1089  
  1090  def _log_domain_removed(**kwargs):
  1091      domain = kwargs['instance']
  1092      msg = "domain {} removed".format(domain)
  1093      log_event(domain.app, msg)
  1094  
  1095  
  1096  def _log_cert_added(**kwargs):
  1097      if kwargs.get('created'):
  1098          cert = kwargs['instance']
  1099          logger.info("cert {} added".format(cert))
  1100  
  1101  
  1102  def _log_cert_removed(**kwargs):
  1103      cert = kwargs['instance']
  1104      logger.info("cert {} removed".format(cert))
  1105  
  1106  
  1107  def _etcd_publish_key(**kwargs):
  1108      key = kwargs['instance']
  1109      _etcd_client.write('/deis/builder/users/{}/{}'.format(
  1110          key.owner.username, fingerprint(key.public)), key.public)
  1111  
  1112  
  1113  def _etcd_purge_key(**kwargs):
  1114      key = kwargs['instance']
  1115      try:
  1116          _etcd_client.delete('/deis/builder/users/{}/{}'.format(
  1117              key.owner.username, fingerprint(key.public)))
  1118      except KeyError:
  1119          pass
  1120  
  1121  
  1122  def _etcd_purge_user(**kwargs):
  1123      username = kwargs['instance'].username
  1124      try:
  1125          _etcd_client.delete(
  1126              '/deis/builder/users/{}'.format(username), dir=True, recursive=True)
  1127      except KeyError:
  1128          # If _etcd_publish_key() wasn't called, there is no user dir to delete.
  1129          pass
  1130  
  1131  
  1132  def _etcd_publish_app(**kwargs):
  1133      appname = kwargs['instance']
  1134      try:
  1135          _etcd_client.write('/deis/services/{}'.format(appname), None, dir=True)
  1136      except KeyError:
  1137          # Ignore error when the directory already exists.
  1138          pass
  1139  
  1140  
  1141  def _etcd_purge_app(**kwargs):
  1142      appname = kwargs['instance']
  1143      try:
  1144          _etcd_client.delete('/deis/services/{}'.format(appname), dir=True, recursive=True)
  1145      except KeyError:
  1146          pass
  1147  
  1148  
  1149  def _etcd_publish_cert(**kwargs):
  1150      cert = kwargs['instance']
  1151      _etcd_client.write('/deis/certs/{}/cert'.format(cert), cert.certificate)
  1152      _etcd_client.write('/deis/certs/{}/key'.format(cert), cert.key)
  1153  
  1154  
  1155  def _etcd_purge_cert(**kwargs):
  1156      cert = kwargs['instance']
  1157      try:
  1158          _etcd_client.delete('/deis/certs/{}'.format(cert),
  1159                              prevExist=True, dir=True, recursive=True)
  1160      except KeyError:
  1161          pass
  1162  
  1163  
  1164  def _etcd_publish_config(**kwargs):
  1165      config = kwargs['instance']
  1166      # we purge all existing config when adding the newest instance. This is because
  1167      # deis config:unset would remove an existing value, but not delete the
  1168      # old config object
  1169      try:
  1170          _etcd_client.delete('/deis/config/{}'.format(config.app),
  1171                              prevExist=True, dir=True, recursive=True)
  1172      except KeyError:
  1173          pass
  1174      for k, v in config.values.iteritems():
  1175          _etcd_client.write(
  1176              '/deis/config/{}/{}'.format(
  1177                  config.app,
  1178                  unicode(k).encode('utf-8').lower()),
  1179              unicode(v).encode('utf-8'))
  1180  
  1181  
  1182  def _etcd_purge_config(**kwargs):
  1183      config = kwargs['instance']
  1184      try:
  1185          _etcd_client.delete('/deis/config/{}'.format(config.app),
  1186                              prevExist=True, dir=True, recursive=True)
  1187      except KeyError:
  1188          pass
  1189  
  1190  
  1191  def _etcd_publish_domains(**kwargs):
  1192      domain = kwargs['instance']
  1193      _etcd_client.write('/deis/domains/{}'.format(domain), domain.app)
  1194  
  1195  
  1196  def _etcd_purge_domains(**kwargs):
  1197      domain = kwargs['instance']
  1198      try:
  1199          _etcd_client.delete('/deis/domains/{}'.format(domain),
  1200                              prevExist=True, dir=True, recursive=True)
  1201      except KeyError:
  1202          pass
  1203  
  1204  
  1205  # Log significant app-related events
  1206  post_save.connect(_log_build_created, sender=Build, dispatch_uid='api.models.log')
  1207  post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log')
  1208  post_save.connect(_log_config_updated, sender=Config, dispatch_uid='api.models.log')
  1209  post_save.connect(_log_domain_added, sender=Domain, dispatch_uid='api.models.log')
  1210  post_save.connect(_log_cert_added, sender=Certificate, dispatch_uid='api.models.log')
  1211  post_delete.connect(_log_domain_removed, sender=Domain, dispatch_uid='api.models.log')
  1212  post_delete.connect(_log_cert_removed, sender=Certificate, dispatch_uid='api.models.log')
  1213  
  1214  
  1215  # automatically generate a new token on creation
  1216  @receiver(post_save, sender=get_user_model())
  1217  def create_auth_token(sender, instance=None, created=False, **kwargs):
  1218      if created:
  1219          Token.objects.create(user=instance)
  1220  
  1221  
  1222  _etcd_client = get_etcd_client()
  1223  
  1224  
  1225  if _etcd_client:
  1226      post_save.connect(_etcd_publish_key, sender=Key, dispatch_uid='api.models')
  1227      post_delete.connect(_etcd_purge_key, sender=Key, dispatch_uid='api.models')
  1228      post_delete.connect(_etcd_purge_user, sender=get_user_model(), dispatch_uid='api.models')
  1229      post_save.connect(_etcd_publish_domains, sender=Domain, dispatch_uid='api.models')
  1230      post_delete.connect(_etcd_purge_domains, sender=Domain, dispatch_uid='api.models')
  1231      post_save.connect(_etcd_publish_app, sender=App, dispatch_uid='api.models')
  1232      post_delete.connect(_etcd_purge_app, sender=App, dispatch_uid='api.models')
  1233      post_save.connect(_etcd_publish_cert, sender=Certificate, dispatch_uid='api.models')
  1234      post_delete.connect(_etcd_purge_cert, sender=Certificate, dispatch_uid='api.models')
  1235      post_save.connect(_etcd_publish_config, sender=Config, dispatch_uid='api.models')
  1236      post_delete.connect(_etcd_purge_config, sender=Config, dispatch_uid='api.models')