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