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