From d8f3d83b5046306100c7d0a83ef4c65f56b9664a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 28 Apr 2016 22:25:28 -0700 Subject: [PATCH] Cleanups --- instance/logger_adapter.py | 46 +++- instance/models/appserver.py | 39 ++- instance/models/instance.py | 128 +++++++++ instance/models/log_entry.py | 14 +- instance/models/mixins/ansible.py | 2 +- instance/models/mixins/openedx_database.py | 182 +++++++++++++ instance/models/mixins/utilities.py | 14 +- instance/models/openedx_appserver.py | 144 +++++----- instance/models/openedx_instance.py | 290 ++++++--------------- instance/models/server.py | 17 +- instance/tasks.py | 10 +- pylintrc | 2 +- 12 files changed, 548 insertions(+), 340 deletions(-) create mode 100644 instance/models/instance.py create mode 100644 instance/models/mixins/openedx_database.py diff --git a/instance/logger_adapter.py b/instance/logger_adapter.py index 1e1daecec..684cf3874 100644 --- a/instance/logger_adapter.py +++ b/instance/logger_adapter.py @@ -24,9 +24,32 @@ import logging +# Helper methods ############################################################## + + +def format_instance(instance): + """ Given any concrete subclass of Instance, get a short ID string to put into the log """ + if instance: + return 'instance={} ({!s:.15})'.format(instance.ref.pk, instance.ref.name) + return 'Unknown Instance' + + +def format_appserver(app_server): + """ Given an AppServer subclass, get a short ID string to put into the log """ + if app_server: + return 'app_server={} ({!s:.15})'.format(app_server.pk, app_server.name) + return 'Unknown AppServer' + + +def format_server(server): + """ Given a Server subclass, get a short ID string to put into the log """ + if server: + return 'server={!s:.20}'.format(server.name) + return 'Unknown Server' # Adapters #################################################################### + class AppServerLoggerAdapter(logging.LoggerAdapter): """ Custom LoggerAdapter for Instance objects @@ -41,6 +64,10 @@ def process(self, msg, kwargs): else: return msg, kwargs + app_server = self.extra['obj'] + msg = '{},{} | {}'.format(format_instance(app_server.instance), format_appserver(app_server), msg) + return msg, kwargs + class ServerLoggerAdapter(logging.LoggerAdapter): """ @@ -51,7 +78,18 @@ def process(self, msg, kwargs): msg, kwargs = super().process(msg, kwargs) server = self.extra['obj'] - if server.instance and server.instance.sub_domain: - return 'instance={!s:.15},server={!s:.8} | {}'.format(server.instance.sub_domain, server, msg), kwargs - else: - return msg, kwargs + msg = '{} | {}'.format(format_server(server), msg) + return msg, kwargs + + +class InstanceLoggerAdapter(logging.LoggerAdapter): + """ + Custom LoggerAdapter for Instance objects + Include the InstanceReference ID in the output + """ + def process(self, msg, kwargs): + msg, kwargs = super().process(msg, kwargs) + + instance = self.extra['obj'] + msg = '{} | {}'.format(format_instance(instance), msg) + return msg, kwargs diff --git a/instance/models/appserver.py b/instance/models/appserver.py index 35e83e860..655dfda62 100644 --- a/instance/models/appserver.py +++ b/instance/models/appserver.py @@ -23,24 +23,18 @@ # Imports ##################################################################### import logging -import string from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.db import models -from django.db.backends.utils import truncate_name -from django.template import loader -from django.utils import timezone -from django.utils.crypto import get_random_string +from django.db.models import Q from django_extensions.db.models import TimeStampedModel -from functools import partial from instance.logger_adapter import AppServerLoggerAdapter -from instance.logging import log_exception +from .instance import InstanceReference from .log_entry import LogEntry -from .mixins.utilities import EmailInstanceMixin -from .mixins.version_control import GitHubInstanceMixin from .server import OpenStackServer -from .utils import ModelResourceStateDescriptor, ResourceState, SteadyStateException, ValidateModelMixin +from .utils import ModelResourceStateDescriptor, ResourceState, ValidateModelMixin # Logging ##################################################################### @@ -164,10 +158,10 @@ class AppServer(ValidateModelMixin, TimeStampedModel): from_states=Status.Running, to_state=Status.Terminated ) - name = models.CharField(max_length=250) - server = models.OneToOneField(OpenStackServer, on_delete=models.CASCADE, related_name='owner') + name = models.CharField(max_length=250, blank=False) + server = models.OneToOneField(OpenStackServer, on_delete=models.CASCADE, related_name='+') # The Instance that owns this. Instance will get related_name accessors like 'openedxappserver_set' - owner = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name='%(class)s_set') + owner = models.ForeignKey(InstanceReference, on_delete=models.CASCADE, related_name='%(class)s_set') class Meta: abstract = True @@ -185,14 +179,16 @@ def instance(self): """ Get the Instance that owns this AppServer """ - return self.owner + return self.owner.instance @property def event_context(self): """ Context dictionary to include in events """ - return {'appserver_id': self.pk, 'instance_id': self.instance.pk} + context = self.instance.event_context # dict with instance_id + context.update({'appserver_id': self.pk, 'appserver_type': self.__class__.__name__}) + return context def set_field_defaults(self): """ @@ -228,9 +224,11 @@ def _get_log_entries(self, level_list=None, limit=None): returned. """ # TODO: Filter out log entries for which the user doesn't have view rights + appserver_type = ContentType.objects.get_for_model(self) + server_type = ContentType.objects.get_for_model(self.server) entries = LogEntry.objects.filter( - (Q(category=LogEntry.CATEGORY_APPSERVER) & Q(obj_id=self.pk)) | - (Q(category=LogEntry.CATEGORY_SERVER) & Q(obj_id=self.server_id)) + (Q(content_type=appserver_type) & Q(object_id=self.pk)) | + (Q(content_type=server_type) & Q(object_id=self.server_id)) ) if level_list: entries = entries.filter(level__in=level_list) @@ -252,10 +250,3 @@ def log_error_entries(self): server it manages """ return self._get_log_entries(level_list=['ERROR', 'CRITICAL']) - - # class ProvisionMessages(object): - # """ - # Class holding ProvisionMessages - # """ - # PROVISION_EXCEPTION = u"Instance provision failed due to unhandled exception" - # PROVISION_ERROR = u"Instance deploy method returned non-zero exit code - provision failed" diff --git a/instance/models/instance.py b/instance/models/instance.py new file mode 100644 index 000000000..2684c2019 --- /dev/null +++ b/instance/models/instance.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# +# OpenCraft -- tools to aid developing and hosting free software projects +# Copyright (C) 2015 OpenCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Instance app models - Open EdX Instance and AppServer models +""" +import logging + +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.functional import cached_property +from django_extensions.db.models import TimeStampedModel + +from instance.logger_adapter import InstanceLoggerAdapter +from .utils import ValidateModelMixin + + +# Logging ##################################################################### + +logger = logging.getLogger(__name__) + + +# Models ###################################################################### + + +class InstanceReference(TimeStampedModel): + """ + InstanceReference: Holds common fields and provides a list of all Instances + + Has name, created, and modified fields for each Instance. + + Instance is an abstract class, so having this common InstanceReference class give us a fully + generic way to iterate through all instances and allow instances to be implemented using a + variety of different python classes and database tables. + """ + name = models.CharField(max_length=250, blank=False, default='Instance') + instance_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + instance_id = models.PositiveIntegerField() + instance = GenericForeignKey('instance_type', 'instance_id') + + class Meta: + ordering = ['-created'] + unique_together = ('instance_type', 'instance_id') + + +class Instance(ValidateModelMixin): + """ + Instance: A web application or suite of web applications. + + An 'Instance' consists of an 'active' AppServer which is available at the instance's URL and + handles all requests from users; the instance may also own some 'terminated' AppServers that + are no longer used, and 'upcoming' AppServers that are used for testing before being + designated as 'active'. + + In the future, we may add a scalable instance type, which owns a pool of active AppServers + that all handle requests; currently at most one AppServer is active at any time. + """ + # ref.id should be used instead of id in most places, so rename the default 'id' field. + inst_id = models.IntegerField(primary_key=True) + # Reverse accessor to get the 'InstanceReference' set. This is a 1:1 relation, so use the + # 'ref' property instead of accessing this directly. The only time to use this directly is + # in a query, e.g. to do .select_related('ref_set') + ref_set = GenericRelation(InstanceReference, content_type_field='instance_type', object_id_field='instance_id') + + class Meta: + abstract = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.logger = InstanceLoggerAdapter(logger, {'obj': self}) + + @cached_property + def ref(self): + """ Get the InstanceReference for this Instance """ + try: + return self.ref_set.get() # self.ref_set is not aware this is a 1:1 relationship. pylint: disable=no-member + except InstanceReference.DoesNotExist: + return InstanceReference(instance=self) + + @property + def name(self): + """ Get this instance's name, which is stored in the InstanceReference """ + return self.ref.name + + @name.setter + def name(self, new_name): + """ Change the 'name' """ + self.ref.name = new_name + + @property + def created(self): + """ Get this instance's created date, which is stored in the InstanceReference """ + return self.ref.created + + @property + def modified(self): + """ Get this instance's modified date, which is stored in the InstanceReference """ + return self.ref.modified + + def save(self, *args, **kwargs): + """ Save this Instance """ + super().save(*args, **kwargs) + # Ensure an InstanceReference exists, and update its 'modified' field: + self.ref.save() + + @property + def event_context(self): + """ + Context dictionary to include in events + """ + return {'instance_id': self.ref.pk} diff --git a/instance/models/log_entry.py b/instance/models/log_entry.py index 569684da4..265f07364 100644 --- a/instance/models/log_entry.py +++ b/instance/models/log_entry.py @@ -21,6 +21,7 @@ """ # Imports ##################################################################### +from django.contrib.contenttypes.models import ContentType from django.db import models from django_extensions.db.models import TimeStampedModel @@ -41,18 +42,9 @@ class LogEntry(ValidateModelMixin, TimeStampedModel): ('CRITICAL', 'Critical'), ) - CATEGORY_GENERAL = None - CATEGORY_APPSERVER = 1 - CATEGORY_SERVER = 2 - CATEGORY_CHOICES = ( - (CATEGORY_GENERAL, 'General'), # Single log entry that isn't attached to a specific model, such as instances or servers - (CATEGORY_APPSERVER, 'AppServer'), # log entry owned by an AppServer (subclass) - (CATEGORY_SERVER, 'Server'), # log entry owned by a Server (subclass) - ) - text = models.TextField(blank=True) - category = models.IntegerField(null=True, choices=CATEGORY_CHOICES) - obj_id = models.IntegerField(null=True, blank=True, default=None) # ID of an AppServer or Server or None + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True) + object_id = models.PositiveIntegerField(null=True) level = models.CharField(max_length=9, db_index=True, default='INFO', choices=LOG_LEVEL_CHOICES) class Meta: diff --git a/instance/models/mixins/ansible.py b/instance/models/mixins/ansible.py index 1b1485c11..cca0e4325 100644 --- a/instance/models/mixins/ansible.py +++ b/instance/models/mixins/ansible.py @@ -99,7 +99,7 @@ def _run_playbook(self, working_dir, playbook): process.wait() return log_lines, process.returncode - def deploy(self): + def run_ansible_playbooks(self): """ Provision the server using ansible """ diff --git a/instance/models/mixins/openedx_database.py b/instance/models/mixins/openedx_database.py new file mode 100644 index 000000000..a1b3f484e --- /dev/null +++ b/instance/models/mixins/openedx_database.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# +# OpenCraft -- tools to aid developing and hosting free software projects +# Copyright (C) 2015 OpenCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Open edX instance database mixin +""" +import string + +from django.conf import settings +from django.db.backends.utils import truncate_name +from django.template import loader +from django.utils.crypto import get_random_string + +from .database import MySQLInstanceMixin, MongoDBInstanceMixin, SwiftContainerInstanceMixin + + +# pylint: disable=too-many-instance-attributes +class OpenEdXDatabasesMixin(MySQLInstanceMixin, MongoDBInstanceMixin, SwiftContainerInstanceMixin): + """ + Mixin that provides functionality required for all the databases that an OpenEdX Instance + uses (when not using ephemeral databases) + """ + + @property + def database_name(self): + """ + The database name used for external databases. + """ + name = self.domain.replace('.', '_') + # Escape all non-ascii characters and truncate to 64 chars, the maximum for mysql: + allowed = string.ascii_letters + string.digits + '_' + escaped = ''.join(char for char in name if char in allowed) + return truncate_name(escaped, length=64) + + @property + def mysql_database_name(self): + """ + The mysql database name for this instance + """ + return self.database_name + + @property + def mysql_database_names(self): + """ + List of mysql database names + """ + return [self.mysql_database_name] + + @property + def mongo_database_name(self): + """ + The name of the main external mongo database + """ + return self.database_name + + @property + def forum_database_name(self): + """ + The name of the external database used for forums + """ + return '{0}_forum'.format(self.database_name) + + @property + def mongo_database_names(self): + """ + List of mongo database names + """ + return [self.mongo_database_name, self.forum_database_name] + + @property + def swift_container_name(self): + """ + The name of the Swift container used by the instance. + """ + return self.database_name + + @property + def swift_container_names(self): + """ + The list of Swift container names to be created. + """ + return [self.swift_container_name] + + def provision_mysql(self): + """ + Set mysql credentials and provision the database. + """ + if not self.mysql_provisioned: + self.mysql_user = get_random_string(length=16, allowed_chars=string.ascii_lowercase) + self.mysql_pass = get_random_string(length=32) + return super().provision_mysql() + + def provision_mongo(self): + """ + Set mongo credentials and provision the database. + """ + if not self.mongo_provisioned: + self.mongo_user = get_random_string(length=16, allowed_chars=string.ascii_lowercase) + self.mongo_pass = get_random_string(length=32) + return super().provision_mongo() + + def provision_swift(self): + """ + Set Swift credentials and create the Swift container. + """ + if settings.SWIFT_ENABLE and not self.swift_provisioned: + # TODO: Figure out a way to use separate credentials for each instance. Access control + # on Swift containers is granted to users, and there doesn't seem to be a way to create + # Keystone users in OpenStack public clouds. + self.swift_openstack_user = settings.SWIFT_OPENSTACK_USER + self.swift_openstack_password = settings.SWIFT_OPENSTACK_PASSWORD + self.swift_openstack_tenant = settings.SWIFT_OPENSTACK_TENANT + self.swift_openstack_auth_url = settings.SWIFT_OPENSTACK_AUTH_URL + self.swift_openstack_region = settings.SWIFT_OPENSTACK_REGION + return super().provision_swift() + + def get_database_settings(self): + """ + Get configuration_database_settings to pass to a new AppServer + + Only needed when not using ephemeral databases + """ + if self.use_ephemeral_databases: + return '' + + new_settings = '' + + # MySQL: + if settings.INSTANCE_MYSQL_URL_OBJ: + template = loader.get_template('instance/ansible/mysql.yml') + new_settings += template.render({ + 'user': self.mysql_user, + 'pass': self.mysql_pass, + 'host': settings.INSTANCE_MYSQL_URL_OBJ.hostname, + 'port': settings.INSTANCE_MYSQL_URL_OBJ.port or 3306, + 'database': self.mysql_database_name + }) + + # MongoDB: + if settings.INSTANCE_MONGO_URL_OBJ: + template = loader.get_template('instance/ansible/mongo.yml') + new_settings += template.render({ + 'user': self.mongo_user, + 'pass': self.mongo_pass, + 'host': settings.INSTANCE_MONGO_URL_OBJ.hostname, + 'port': settings.INSTANCE_MONGO_URL_OBJ.port or 27017, + 'database': self.mongo_database_name, + 'forum_database': self.forum_database_name + }) + + # S3: + if self.s3_access_key and self.s3_secret_access_key and self.s3_bucket_name: + template = loader.get_template('instance/ansible/s3.yml') + new_settings += template.render({'instance': self}) + + # SWIFT: + if settings.SWIFT_ENABLE: + template = loader.get_template('instance/ansible/swift.yml') + return template.render({ + 'user': self.swift_openstack_user, + 'password': self.swift_openstack_password, + 'tenant': self.swift_openstack_tenant, + 'auth_url': self.swift_openstack_auth_url, + 'region': self.swift_openstack_region + }) + + return new_settings diff --git a/instance/models/mixins/utilities.py b/instance/models/mixins/utilities.py index 99b5a3408..60e326ffd 100644 --- a/instance/models/mixins/utilities.py +++ b/instance/models/mixins/utilities.py @@ -26,22 +26,22 @@ from django.views.debug import ExceptionReporter -class EmailInstanceMixin(object): +class EmailMixin: """ - An instance class that can send emails + An AppServer class that can send emails """ class EmailSubject(object): """ Class holding email subject constants """ - PROVISION_FAILED = u"Instance {instance_name} ({instance_url}) failed to provision" + PROVISION_FAILED = "AppServer {name} ({instance_name}) failed to provision" class EmailBody(object): """ Class holding email body constants """ - PROVISION_FAILED = u"Instance {instance_name} failed to provision.\n" \ - u"Reason: {reason}\n" + PROVISION_FAILED = "Instance {instance_name} AppServer {name} failed to provision.\n" \ + "Reason: {reason}\n" @staticmethod def _get_exc_info(default=None): @@ -65,8 +65,8 @@ def provision_failed_email(self, reason, log=None): attachments.append(("provision.log", log_str, "text/plain")) self._send_email( - self.EmailSubject.PROVISION_FAILED.format(instance_name=self.name, instance_url=self.url), - self.EmailBody.PROVISION_FAILED.format(instance_name=self.name, reason=reason), + self.EmailSubject.PROVISION_FAILED.format(name=self.name, instance_name=self.instance.name), + self.EmailBody.PROVISION_FAILED.format(name=self.name, instance_name=self.instance.name, reason=reason), self._get_exc_info(default=None), attachments=attachments ) diff --git a/instance/models/openedx_appserver.py b/instance/models/openedx_appserver.py index d42b6d48d..3965cdf63 100644 --- a/instance/models/openedx_appserver.py +++ b/instance/models/openedx_appserver.py @@ -19,13 +19,17 @@ """ Instance app models - Open EdX AppServer models """ +from django.conf import settings from django.db import models from django.template import loader +from django.utils.text import slugify from instance import ansible -from .mixins.ansible import AnsibleAppServerMixin, Playbook -from .mixins.database import MongoDBInstanceMixin, MySQLInstanceMixin, SwiftContainerInstanceMixin +from instance.logging import log_exception from .appserver import AppServer +from .mixins.ansible import AnsibleAppServerMixin, Playbook +from .mixins.utilities import EmailMixin +from .utils import SteadyStateException # Constants ################################################################### @@ -55,7 +59,7 @@ class Meta: # Ansible-specific settings: configuration_source_repo_url = models.URLField(max_length=256, blank=False) configuration_version = models.CharField(max_length=50, blank=False) - configuration_extra_settings = models.TextField(blank=True) + configuration_extra_settings = models.TextField(blank=True, help_text="YAML config vars that override all others") edx_platform_repository_url = models.CharField(max_length=256, blank=False, help_text=( 'URL to the edx-platform repository to use. Leave blank for default.' @@ -102,8 +106,7 @@ def set_field_defaults(self): super().set_field_defaults() - -class OpenEdXAppServer(AppServer, OpenEdXAppConfiguration): +class OpenEdXAppServer(AppServer, OpenEdXAppConfiguration, AnsibleAppServerMixin, EmailMixin): """ OpenEdXAppServer: One or more of the Open edX apps, running on a single VM @@ -111,23 +114,22 @@ class OpenEdXAppServer(AppServer, OpenEdXAppConfiguration): * edxapp (LMS+Studio) * cs_comments_service (forums) * notifier - * notifier * xqueue * insights """ name = models.CharField(max_length=250) + + configuration_database_settings = models.TextField(blank=True, help_text="YAML vars for file and DB configuration") # configuration_settings: A record of the combined (final) ansible variables passed to # the configuration playbook when configuring this AppServer. configuration_settings = models.TextField(blank=False) CONFIGURATION_PLAYBOOK = 'edx_sandbox' CONFIGURATION_VARS_TEMPLATE = 'instance/ansible/vars.yml' - # Additional model fields that contain yaml vars to add the the configuration vars: - CONFIGURATION_ANSIBLE_EXTRA_FIELDS = [ - 'ansible_s3_settings', - 'ansible_mysql_settings', - 'ansible_mongo_settings', - 'ansible_swift_settings', + # Additional model fields/properties that contain yaml vars to add the the configuration vars: + CONFIGURATION_EXTRA_FIELDS = [ + 'configuration_database_settings', + 'configuration_extra_settings', ] def set_field_defaults(self): @@ -163,71 +165,69 @@ def create_configuration_settings(self): """ template = loader.get_template(self.CONFIGURATION_VARS_TEMPLATE) vars_str = template.render({ - 'instance': self.instance, - 'appserver': self, + 'instance': self, # TODO: Rename this template var to 'appserver' # This property is needed twice in the template. To avoid evaluating it twice (and # querying the Github API twice), we pass it as a context variable. - 'github_admin_username_list': self.github_admin_username_list, + 'github_admin_username_list': self.instance.github_admin_username_list, }) - for attr_name in self.CONFIGURATION_ANSIBLE_EXTRA_FIELDS: + for attr_name in self.CONFIGURATION_EXTRA_FIELDS: additional_vars = getattr(self, attr_name) vars_str = ansible.yaml_merge(vars_str, additional_vars) self.logger.debug('Vars.yml:\n%s', vars_str) return vars_str - @property - def ansible_mysql_settings(self): - """ - Ansible settings for the external mysql database - """ - if self.use_ephemeral_databases or not settings.INSTANCE_MYSQL_URL_OBJ: - return '' - - template = loader.get_template('instance/ansible/mysql.yml') - return template.render({'user': self.mysql_user, - 'pass': self.mysql_pass, - 'host': settings.INSTANCE_MYSQL_URL_OBJ.hostname, - 'port': settings.INSTANCE_MYSQL_URL_OBJ.port or 3306, - 'database': self.mysql_database_name}) - - @property - def ansible_mongo_settings(self): - """ - Ansible settings for the external mongo database - """ - if self.use_ephemeral_databases or not settings.INSTANCE_MONGO_URL_OBJ: - return '' - - template = loader.get_template('instance/ansible/mongo.yml') - return template.render({'user': self.mongo_user, - 'pass': self.mongo_pass, - 'host': settings.INSTANCE_MONGO_URL_OBJ.hostname, - 'port': settings.INSTANCE_MONGO_URL_OBJ.port or 27017, - 'database': self.mongo_database_name, - 'forum_database': self.forum_database_name}) - - @property - def ansible_s3_settings(self): - """ - Ansible settings for the S3 bucket - """ - if not self.s3_access_key or not self.s3_secret_access_key or not self.s3_bucket_name: - return '' - - template = loader.get_template('instance/ansible/s3.yml') - return template.render({'instance': self}) - - @property - def ansible_swift_settings(self): - """ - Ansible settings for Swift access. - """ - if self.use_ephemeral_databases or not settings.SWIFT_ENABLE: - return '' - - template = loader.get_template('instance/ansible/swift.yml') - return template.render({'user': self.swift_openstack_user, - 'password': self.swift_openstack_password, - 'tenant': self.swift_openstack_tenant, - 'auth_url': self.swift_openstack_auth_url, - 'region': self.swift_openstack_region}) + @log_exception + @AppServer.status.only_for(AppServer.Status.New) + def provision(self): + """ + Provision this AppServer. + + Returns True on success or False on failure + """ + self.logger.info('Starting provisioning') + # Start by requesting a new server/VM: + self._status_to_waiting_for_server() + assert self.server_id is None + server_prefix = ('edxapp-' + slugify(self.instance.sub_domain))[:20] + self.server.create(name_prefix=server_prefix) + self.server.start() + + def accepts_ssh_commands(): + """ Does server accept SSH commands? """ + return self.server.status.accepts_ssh_commands + + try: + self.logger.info('Waiting for server %s...', self.server) + self.server.sleep_until(lambda: self.server.status.vm_available) + self.logger.info('Waiting for server %s to finish booting...', self.server) + self.server.sleep_until(accepts_ssh_commands) + except SteadyStateException: + self._status_to_error() + return False + + try: + # Provisioning (ansible) + self.logger.info('Provisioning server...') + self._status_to_configuring_server() + log, exit_code = self.run_ansible_playbooks() + if exit_code != 0: + self.logger.info('Provisioning failed') + self._status_to_configuration_failed() + self.provision_failed_email("AppServer deploy failed: ansible play exited with non-zero exit code", log) + return False + + # Reboot + self.logger.info('Provisioning completed') + self.logger.info('Rebooting server %s...', self.server) + self.server.reboot() + self.server.sleep_until(accepts_ssh_commands) + + # Declare instance up and running + self._status_to_running() + + return True + + except: + self.server.terminate() + self.provision_failed_email("AppServer deploy failed: unhandled exception") + raise diff --git a/instance/models/openedx_instance.py b/instance/models/openedx_instance.py index 32fd33861..bf67d1f4f 100644 --- a/instance/models/openedx_instance.py +++ b/instance/models/openedx_instance.py @@ -17,14 +17,17 @@ # along with this program. If not, see . # """ -Instance app models - Open EdX Instance and AppServer models +Open edX Instance models """ -from django_extensions.db.models import TimeStampedModel +from django.conf import settings +from django.db import models from instance.gandi import GandiAPI -from .appserver import AppServer -from .openedx_appserver import OpenEdXAppConfiguration -from .utils import ValidateModelMixin +from instance.logging import log_exception +from .mixins.openedx_database import OpenEdXDatabasesMixin +from .mixins.version_control import GitHubInstanceMixin +from .instance import Instance +from .openedx_appserver import OpenEdXAppConfiguration, OpenEdXAppServer # Constants ################################################################### @@ -33,28 +36,9 @@ # Models ###################################################################### -class Instance(ValidateModelMixin, TimeStampedModel): +class BaseOpenEdXInstance(Instance, OpenEdXAppConfiguration, OpenEdXDatabasesMixin): """ - Instance: A web application or suite of web applications. - - An 'Instance' consists of an 'active' AppServer which is available at the instance's URL and - handles all requests from users; the instance may also own some 'terminated' AppServers that - are no longer used, and 'upcoming' AppServers that are used for testing before being - designated as 'active'. - - In the future, we may add a scalable instance type, which owns a pool of active AppServers - that all handle requests; currently at most one AppServer is active at any time. - """ - name = models.CharField(max_length=250) - - class Meta: - ordering = ['-created'] - - -class OpenEdXInstance(Instance, OpenEdXAppConfiguration, MySQLInstanceMixin, MongoDBInstanceMixin, - SwiftContainerInstanceMixin, GitHubInstanceMixin, EmailInstanceMixin): - """ - OpenEdXInstance: represents a website or set of affiliated websites powered by the same + BaseOpenEdXInstance: represents a website or set of affiliated websites powered by the same OpenEdX installation. """ @@ -62,7 +46,10 @@ class OpenEdXInstance(Instance, OpenEdXAppConfiguration, MySQLInstanceMixin, Mon sub_domain = models.CharField(max_length=50) base_domain = models.CharField(max_length=50, blank=True) + active_appserver = models.OneToOneField(OpenEdXAppServer, null=True, on_delete=models.SET_NULL, related_name='+') + class Meta: + abstract = True unique_together = ('base_domain', 'sub_domain') verbose_name = 'Open edX Instance' @@ -76,40 +63,10 @@ def domain(self): @property def url(self): """ - Instance URL + LMS URL """ - # TODO: Delete this... replace w/ lms_url, studio_url, insights_url etc. return u'{0.protocol}://{0.domain}/'.format(self) - def save(self, **kwargs): - """ - Set default values before saving the instance. - """ - # Set default field values from settings - using the `default` field attribute confuses - # automatically generated migrations, generating a new one when settings don't match - if not self.base_domain: - self.base_domain = settings.INSTANCES_BASE_DOMAIN - if self.use_ephemeral_databases is None: - self.use_ephemeral_databases = settings.INSTANCE_EPHEMERAL_DATABASES - super().save(**kwargs) - - def update_from_pr(self, pr): - """ - Update this instance with settings from the given pull request - """ - super().update_from_pr(pr) - self.ansible_extra_settings = pr.extra_settings - self.use_ephemeral_databases = pr.use_ephemeral_databases(self.domain) - self.ansible_source_repo_url = pr.get_extra_setting('edx_ansible_source_repo') - self.configuration_version = pr.get_extra_setting('configuration_version') - - @property - def default_fork(self): - """ - Name of the fork to use by default, when no repository is specified - """ - return settings.DEFAULT_FORK - @property def studio_sub_domain(self): """ @@ -131,170 +88,91 @@ def studio_url(self): """ return u'{0.protocol}://{0.studio_domain}/'.format(self) - @property - def database_name(self): - """ - The database name used for external databases. Escape all non-ascii characters and truncate to 64 chars, the - maximum for mysql - """ - name = self.domain.replace('.', '_') - allowed = string.ascii_letters + string.digits + '_' - escaped = ''.join(char for char in name if char in allowed) - return truncate_name(escaped, length=64) - - @property - def mysql_database_name(self): - """ - The mysql database name for this instance - """ - return self.database_name - - @property - def mysql_database_names(self): - """ - List of mysql database names - """ - return [self.mysql_database_name] - - @property - def mongo_database_name(self): - """ - The name of the main external mongo database - """ - return self.database_name - - @property - def forum_database_name(self): - """ - The name of the external database used for forums - """ - return '{0}_forum'.format(self.database_name) - - @property - def mongo_database_names(self): + def save(self, **kwargs): """ - List of mongo database names + Set default values before saving the instance. """ - return [self.mongo_database_name, self.forum_database_name] + # Set default field values from settings - using the `default` field attribute confuses + # automatically generated migrations, generating a new one when settings don't match + if not self.base_domain: + self.base_domain = settings.INSTANCES_BASE_DOMAIN + if self.use_ephemeral_databases is None: + self.use_ephemeral_databases = settings.INSTANCE_EPHEMERAL_DATABASES + super().save(**kwargs) @property - def swift_container_name(self): + def appserver_set(self): """ - The name of the Swift container used by the instance. + Get the set of OpenEdxAppServers owned by this instance. """ - return self.database_name + return self.ref.openedxappserver_set - @property - def swift_container_names(self): + def set_appserver_active(self, appserver_id): """ - The list of Swift container names to be created. + Mark the AppServer with the given ID as the active one. """ - return [self.swift_container_name] + app_server = self.appserver_set.get(pk=appserver_id) # Make sure the AppServer is owned by this instance + self.active_appserver = app_server + self.save() + self.logger.info('Updating DNS: LMS at %s...', self.domain) + gandi.set_dns_record(type='A', name=self.sub_domain, value=self.active_appserver.server.public_ip) + self.logger.info('Updating DNS: Studio at %s...', self.studio_domain) + gandi.set_dns_record(type='CNAME', name=self.studio_sub_domain, value=self.sub_domain) @log_exception - def provision(self): - """ - Run the provisioning sequence of the instance, recreating the servers from scratch - - Returns: (server, log) - """ - self.last_provisioning_started = timezone.now() - - # Instance - self.logger.info('Prepare for (re-)provisioning') - self._status_to_waiting_for_server() - - # Server - self.logger.info('Terminate servers') - self.server_set.terminate() - self.logger.info('Start new server') - server = self.server_set.create() - server.start() - - try: - server.sleep_until(lambda: server.status.vm_available) - except SteadyStateException: - self._status_to_error() - return (server, None) - - def accepts_ssh_commands(): - """ Does server accept SSH commands? """ - return server.status.accepts_ssh_commands - - try: - # DNS - self.logger.info('Waiting for IP assignment on server %s...', server) - server.sleep_until(accepts_ssh_commands) - self.logger.info('Updating DNS: LMS at %s...', self.domain) - gandi.set_dns_record(type='A', name=self.sub_domain, value=server.public_ip) - self.logger.info('Updating DNS: Studio at %s...', self.studio_domain) - gandi.set_dns_record(type='CNAME', name=self.studio_sub_domain, value=self.sub_domain) - - # Provisioning (external databases) - if not self.use_ephemeral_databases: - self.logger.info('Provisioning MySQL database...') - self.provision_mysql() - self.logger.info('Provisioning MongoDB databases...') - self.provision_mongo() - self.logger.info('Provisioning Swift container...') - self.provision_swift() - - # Provisioning (ansible) - self.logger.info('Provisioning server...') - self._status_to_configuring_server() - self.reset_ansible_settings(commit=True) - log, exit_code = self.deploy() - if exit_code != 0: - self.logger.info('Provisioning failed') - self._status_to_configuration_failed() - self.provision_failed_email(self.ProvisionMessages.PROVISION_ERROR, log) - return (server, log) - - # Reboot - self.logger.info('Provisioning completed') - self.logger.info('Rebooting server %s...', server) - server.reboot() - server.sleep_until(accepts_ssh_commands) - - # Declare instance up and running - self._status_to_running() - - return (server, log) - - except: - self.server_set.terminate() - self.provision_failed_email(self.ProvisionMessages.PROVISION_EXCEPTION) - raise + def spawn_appserver(self): + """ + Provision a new AppServer. If it completes successfully, mark it as active. + """ + # Provision external databases: + if not self.use_ephemeral_databases: + # TODO: Use db row-level locking to ensure we don't get any race conditions when creating these DBs. + # Use select_for_update(nowait=True) to lock this object's row, then do these steps, then refresh_from_db + self.logger.info('Provisioning MySQL database...') + self.provision_mysql() + self.logger.info('Provisioning MongoDB databases...') + self.provision_mongo() + self.logger.info('Provisioning Swift container...') + self.provision_swift() + + config_fields = [field.name for field in OpenEdXAppConfiguration._meta.fields if field.name not in ('id', )] + config_copy = {field_name: getattr(self, field_name) for field_name in config_fields} + + app_server = self.appserver_set.create( + # Name for the app server: this should usually generate a unique name (and won't cause any issues if not): + name="AppServer {}".format(self.appserver_set.count() + 1), + # Copy the current value of each setting into the AppServer, preserving it permanently: + configuration_database_settings=self.get_database_settings(), + **config_copy + ) + if app_server.provision(): + # If the AppServer provisioned successfully, make it the active one: + # Note: if I call spawn_appserver() twice, and the second one provisions sooner, the first one may then + # finish and replace the second as the active server. We are not really worried about that for now. + self.set_appserver_active(app_server.pk) + + +class SandboxInstance(BaseOpenEdXInstance, GitHubInstanceMixin): + """ + SandboxInstance: represents an Open edX instance created for a pull request. + """ - def provision_mysql(self): - """ - Set mysql credentials and provision the database. - """ - if not self.mysql_provisioned: - self.mysql_user = get_random_string(length=16, allowed_chars=string.ascii_lowercase) - self.mysql_pass = get_random_string(length=32) - return super().provision_mysql() + class Meta: + verbose_name = 'Open edX Sandbox Instance' - def provision_mongo(self): + def update_from_pr(self, pr): """ - Set mongo credentials and provision the database. + Update this instance with settings from the given pull request """ - if not self.mongo_provisioned: - self.mongo_user = get_random_string(length=16, allowed_chars=string.ascii_lowercase) - self.mongo_pass = get_random_string(length=32) - return super().provision_mongo() + super().update_from_pr(pr) + self.configuration_extra_settings = pr.extra_settings + self.use_ephemeral_databases = pr.use_ephemeral_databases(self.domain) + self.configuration_source_repo_url = pr.get_extra_setting('edx_ansible_source_repo') + self.configuration_version = pr.get_extra_setting('configuration_version') - def provision_swift(self): + @property + def default_fork(self): """ - Set Swfit credentials and create the Swift container. + Name of the fork to use by default, when no repository is specified """ - if settings.SWIFT_ENABLE and not self.swift_provisioned: - # TODO: Figure out a way to use separate credentials for each instance. Access control - # on Swift containers is granted to users, and there doesn't seem to be a way to create - # Keystone users in OpenStack public clouds. - self.swift_openstack_user = settings.SWIFT_OPENSTACK_USER - self.swift_openstack_password = settings.SWIFT_OPENSTACK_PASSWORD - self.swift_openstack_tenant = settings.SWIFT_OPENSTACK_TENANT - self.swift_openstack_auth_url = settings.SWIFT_OPENSTACK_AUTH_URL - self.swift_openstack_region = settings.SWIFT_OPENSTACK_REGION - return super().provision_swift() + return settings.DEFAULT_FORK diff --git a/instance/models/server.py b/instance/models/server.py index c29341f46..ac9201ffb 100644 --- a/instance/models/server.py +++ b/instance/models/server.py @@ -38,7 +38,6 @@ from instance.logger_adapter import ServerLoggerAdapter from instance.utils import is_port_open, to_json -from instance.models.instance import SingleVMOpenEdXInstance from instance.models.utils import ( ValidateModelMixin, ResourceState, ModelResourceStateDescriptor, SteadyStateException ) @@ -142,6 +141,8 @@ class Server(ValidateModelMixin, TimeStampedModel): """ A single server VM """ + name_prefix = models.SlugField(max_length=20, blank=False) + Status = Status status = ModelResourceStateDescriptor( state_classes=Status.states, default_state=Status.Pending, model_field_name='_status' @@ -176,19 +177,17 @@ def __init__(self, *args, **kwargs): self.logger = ServerLoggerAdapter(logger, {'obj': self}) @property - def app_server(self): - """ Get the AppServer that owns this Server """ - return self.owner + def name(self): + """ Get a name for this server (slug-friendly) """ + assert self.id is not None + return "{prefix}-{num}".format(prefix=self.name_prefix, num=self.id) @property def event_context(self): """ Context dictionary to include in events """ - return { - 'instance_id': self.instance.pk, - 'server_id': self.pk, - } + return {'server_id': self.pk} def sleep_until(self, condition, timeout=3600): """ @@ -330,7 +329,7 @@ def start(self): try: os_server = openstack.create_server( self.nova, - self.instance.sub_domain, + self.name, settings.OPENSTACK_SANDBOX_FLAVOR, settings.OPENSTACK_SANDBOX_BASE_IMAGE, key_name=settings.OPENSTACK_SANDBOX_SSH_KEYNAME, diff --git a/instance/tasks.py b/instance/tasks.py index 6b9351f3b..12ef5e3cb 100644 --- a/instance/tasks.py +++ b/instance/tasks.py @@ -27,7 +27,7 @@ from django.conf import settings from instance.github import get_username_list_from_team, get_pr_list_from_username -from instance.models.instance import SingleVMOpenEdXInstance +from instance.models.openedx_instance import SandboxInstance # Logging ##################################################################### @@ -44,10 +44,10 @@ def provision_instance(instance_pk): Run provisioning on an existing instance """ logger.info('Retreiving instance: pk=%s', instance_pk) - instance = SingleVMOpenEdXInstance.objects.get(pk=instance_pk) + instance = SandboxInstance.objects.get(pk=instance_pk) - logger.info('Running provisioning on %s', instance) - instance.provision() + logger.info('Spawning new AppServer on %s', instance) + instance.spawn_appserver() @db_periodic_task(crontab(minute='*/1')) @@ -61,7 +61,7 @@ def watch_pr(): for username in team_username_list: for pr in get_pr_list_from_username(username, settings.WATCH_FORK): sub_domain = 'pr{number}.sandbox'.format(number=pr.number) - instance, created = SingleVMOpenEdXInstance.objects.update_or_create_from_pr(pr, sub_domain) + instance, created = SandboxInstance.objects.update_or_create_from_pr(pr, sub_domain) if created: logger.info('New PR found, creating sandbox: %s', pr) provision_instance(instance.pk) diff --git a/pylintrc b/pylintrc index ec060c3be..2d5f65a46 100644 --- a/pylintrc +++ b/pylintrc @@ -270,7 +270,7 @@ function-name-hint=[a-z_][a-z0-9_]{2,50}$ # Regular expression which should only match function or class names that do # not require a docstring. -no-docstring-rgx=__.*__ +no-docstring-rgx=(__.*__)|Meta # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt.