github.com/jiasir/deis@v1.12.2/controller/api/models.py (about)

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