diff --git a/rootfs/api/migrations/0013_auto_20160812_1905.py b/rootfs/api/migrations/0013_auto_20160812_1905.py new file mode 100644 index 000000000..bee90a7d4 --- /dev/null +++ b/rootfs/api/migrations/0013_auto_20160812_1905.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-12 19:05 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0012_auto_20160810_1603'), + ] + + operations = [ + migrations.CreateModel( + name='AppSettings', + fields=[ + ('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('maintenance', models.BooleanField(default=False)), + ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.App')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'get_latest_by': 'created', + 'ordering': ['-created'], + }, + ), + migrations.AlterUniqueTogether( + name='appsettings', + unique_together=set([('app', 'uuid')]), + ), + ] diff --git a/rootfs/api/models/__init__.py b/rootfs/api/models/__init__.py index a545f7f07..f344c01fb 100644 --- a/rootfs/api/models/__init__.py +++ b/rootfs/api/models/__init__.py @@ -117,6 +117,7 @@ class Meta: from .release import Release # noqa from .config import Config # noqa from .build import Build # noqa +from .appsettings import AppSettings # noqa # define update/delete callbacks for synchronizing # models with the configuration management backend @@ -142,6 +143,12 @@ def _log_config_updated(**kwargs): config.app.log("config {} updated".format(config)) +def _log_app_settings_updated(**kwargs): + appSettings = kwargs['instance'] + # log only to the controller; this event will be logged in the release summary + appSettings.app.log("application settings {} updated".format(appSettings)) + + def _log_domain_added(**kwargs): if kwargs.get('created'): domain = kwargs['instance'] @@ -170,6 +177,7 @@ def _log_cert_removed(**kwargs): post_save.connect(_log_config_updated, sender=Config, dispatch_uid='api.models.log') post_save.connect(_log_domain_added, sender=Domain, dispatch_uid='api.models.log') post_save.connect(_log_cert_added, sender=Certificate, dispatch_uid='api.models.log') +post_save.connect(_log_app_settings_updated, sender=AppSettings, dispatch_uid='api.models.log') post_delete.connect(_log_domain_removed, sender=Domain, dispatch_uid='api.models.log') post_delete.connect(_log_cert_removed, sender=Certificate, dispatch_uid='api.models.log') diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py index a3482739b..3adda7d26 100644 --- a/rootfs/api/models/app.py +++ b/rootfs/api/models/app.py @@ -23,6 +23,7 @@ from api.models.release import Release from api.models.config import Config from api.models.domain import Domain +from api.models.appsettings import AppSettings from scheduler import KubeHTTPException, KubeException @@ -178,7 +179,7 @@ def log(self, message, level=logging.INFO): def create(self, *args, **kwargs): # noqa """ - Create a application with an initial config, release, domain + Create a application with an initial config, settings, release, domain and k8s resource if needed """ try: @@ -220,6 +221,10 @@ def create(self, *args, **kwargs): # noqa raise ServiceUnavailable('Kubernetes resources could not be created') from e + try: + self.appsettings_set.latest() + except AppSettings.DoesNotExist: + AppSettings.objects.create(owner=self.owner, app=self) # Attach the platform specific application sub domain to the k8s service # Only attach it on first release in case a customer has remove the app domain if rel.version == 1 and not Domain.objects.filter(domain=self.id).exists(): @@ -389,6 +394,7 @@ def scale(self, user, structure): # noqa def _scale_pods(self, scale_types): release = self.release_set.latest() + app_settings = self.appsettings_set.latest() version = "v{}".format(release.version) image = release.image envs = self._build_env_vars(release.build.type, version, image, release.config.values) @@ -424,6 +430,7 @@ def _scale_pods(self, scale_types): 'app_type': scale_type, 'build_type': release.build.type, 'healthcheck': healthcheck, + 'annotations': {'maintenance': app_settings.maintenance}, 'routable': routable, 'deploy_batches': batches, 'deploy_timeout': deploy_timeout, @@ -461,6 +468,8 @@ def deploy(self, release, force_deploy=False): if release.build is None: raise DeisException('No build associated with this release') + app_settings = self.appsettings_set.latest() + # use create to make sure minimum resources are created self.create() @@ -509,6 +518,7 @@ def deploy(self, release, force_deploy=False): 'build_type': release.build.type, 'healthcheck': healthcheck, 'routable': routable, + 'annotations': {'maintenance': app_settings.maintenance}, 'deploy_batches': batches, 'deploy_timeout': deploy_timeout, 'deployment_history_limit': deployment_history, diff --git a/rootfs/api/models/appsettings.py b/rootfs/api/models/appsettings.py new file mode 100644 index 000000000..4d00cdde9 --- /dev/null +++ b/rootfs/api/models/appsettings.py @@ -0,0 +1,65 @@ +import logging +from django.conf import settings +from django.db import models + +from api.models import UuidAuditedModel +from api.exceptions import DeisException, AlreadyExists +from scheduler import KubeException + + +class AppSettings(UuidAuditedModel): + """ + Instance of Application settings used by scheduler + """ + + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + app = models.ForeignKey('App', on_delete=models.CASCADE) + maintenance = models.BooleanField(default=False) + + class Meta: + get_latest_by = 'created' + unique_together = (('app', 'uuid')) + ordering = ['-created'] + + def __str__(self): + return "{}-{}".format(self.app.id, str(self.uuid)[:7]) + + def set_maintenance(self, maintenance): + namespace = self.app.id + service = self._fetch_service_config(namespace) + old_service = service.copy() # in case anything fails for rollback + + try: + service['metadata']['annotations']['router.deis.io/maintenance'] = str(maintenance) + self._scheduler.update_service(namespace, namespace, data=service) + except Exception as e: + self._scheduler.update_service(namespace, namespace, data=old_service) + raise KubeException(str(e)) from e + + def save(self, *args, **kwargs): + summary = '' + previous_settings = None + try: + previous_settings = self.app.appsettings_set.latest() + except AppSettings.DoesNotExist: + pass + + prev_maintenance = getattr(previous_settings, 'maintenance', None) + new_maintenance = getattr(self, 'maintenance') + + try: + if new_maintenance is None and prev_maintenance is not None: + setattr(self, 'maintenance', prev_maintenance) + elif prev_maintenance != new_maintenance: + self.set_maintenance(new_maintenance) + summary += "{} changed maintenance mode from {} to {}".format(self.owner, prev_maintenance, new_maintenance) # noqa + except Exception as e: + self.delete() + raise DeisException(str(e)) from e + + if not summary and previous_settings: + self.delete() + raise AlreadyExists("{} changed nothing".format(self.owner)) + self.app.log('summary of app setting changes: {}'.format(summary), logging.DEBUG) + + return super(AppSettings, self).save(**kwargs) diff --git a/rootfs/api/serializers.py b/rootfs/api/serializers.py index 14b14c651..96c5101b6 100644 --- a/rootfs/api/serializers.py +++ b/rootfs/api/serializers.py @@ -479,3 +479,15 @@ class PodSerializer(serializers.BaseSerializer): def to_representation(self, obj): return obj + + +class AppSettingsSerializer(serializers.ModelSerializer): + """Serialize a :class:`~api.models.AppSettings` model.""" + + app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all()) + owner = serializers.ReadOnlyField(source='owner.username') + + class Meta: + """Metadata options for a :class:`AppSettingsSerializer`.""" + model = models.AppSettings + fields = '__all__' diff --git a/rootfs/api/tests/test_app_settings.py b/rootfs/api/tests/test_app_settings.py new file mode 100644 index 000000000..82fabaa59 --- /dev/null +++ b/rootfs/api/tests/test_app_settings.py @@ -0,0 +1,66 @@ +import requests_mock + +from django.core.cache import cache +from django.contrib.auth.models import User +from rest_framework.authtoken.models import Token + +from api.models import App +from api.tests import adapter, DeisTransactionTestCase + + +@requests_mock.Mocker(real_http=True, adapter=adapter) +class TestAppSettings(DeisTransactionTestCase): + """Tests setting and updating config values""" + + fixtures = ['tests.json'] + + def setUp(self): + self.user = User.objects.get(username='autotest') + self.token = Token.objects.get(user=self.user).key + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) + + def tearDown(self): + # make sure every test has a clean slate for k8s mocking + cache.clear() + + def test_settings_maintenance(self, mock_requests): + """ + Test that maintenance can be applied + """ + app_id = self.create_app() + app = App.objects.get(id=app_id) + + settings = {'maintenance': True} + response = self.client.post( + '/v2/apps/{app_id}/settings'.format(**locals()), + settings) + self.assertEqual(response.status_code, 201, response.data) + self.assertTrue(response.data['maintenance']) + self.assertTrue(app.appsettings_set.latest().maintenance) + + settings['maintenance'] = False + response = self.client.post( + '/v2/apps/{app_id}/settings'.format(**locals()), + settings) + self.assertEqual(response.status_code, 201, response.data) + self.assertFalse(response.data['maintenance']) + self.assertFalse(app.appsettings_set.latest().maintenance) + + response = self.client.post( + '/v2/apps/{app_id}/settings'.format(**locals()), + settings) + self.assertEqual(response.status_code, 409, response.data) + self.assertFalse(app.appsettings_set.latest().maintenance) + + settings = {} + response = self.client.post( + '/v2/apps/{app_id}/settings'.format(**locals()), + settings) + self.assertEqual(response.status_code, 409, response.data) + self.assertFalse(app.appsettings_set.latest().maintenance) + + settings['maintenance'] = "test" + response = self.client.post( + '/v2/apps/{app_id}/settings'.format(**locals()), + settings) + self.assertEqual(response.status_code, 400, response.data) diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py index 1246d47cd..fd28b9189 100644 --- a/rootfs/api/urls.py +++ b/rootfs/api/urls.py @@ -57,6 +57,9 @@ views.AppViewSet.as_view({'get': 'logs'})), url(r"^apps/(?P{})/run/?".format(settings.APP_URL_REGEX), views.AppViewSet.as_view({'post': 'run'})), + # application settings + url(r"^apps/(?P{})/settings/?".format(settings.APP_URL_REGEX), + views.AppSettingsViewSet.as_view({'get': 'retrieve', 'post': 'create'})), # apps sharing url(r"^apps/(?P{})/perms/(?P[-_\w]+)/?".format(settings.APP_URL_REGEX), views.AppPermsViewSet.as_view({'delete': 'destroy'})), diff --git a/rootfs/api/views.py b/rootfs/api/views.py index c73d57ec4..5a5c94d45 100644 --- a/rootfs/api/views.py +++ b/rootfs/api/views.py @@ -296,6 +296,11 @@ def restart(self, *args, **kwargs): return Response(pagination, status=status.HTTP_200_OK) +class AppSettingsViewSet(AppResourceViewSet): + model = models.AppSettings + serializer_class = serializers.AppSettingsSerializer + + class DomainViewSet(AppResourceViewSet): """A viewset for interacting with Domain objects.""" model = models.Domain diff --git a/rootfs/scheduler/__init__.py b/rootfs/scheduler/__init__.py index 94e3f80e6..633a45b4a 100644 --- a/rootfs/scheduler/__init__.py +++ b/rootfs/scheduler/__init__.py @@ -89,6 +89,7 @@ def deploy(self, namespace, name, image, entrypoint, command, **kwargs): # noqa app_type = kwargs.get('app_type') routable = kwargs.get('routable', False) + annotations = kwargs.get('annotations', {}) envs = kwargs.get('envs', {}) port = envs.get('PORT', None) @@ -134,7 +135,7 @@ def deploy(self, namespace, name, image, entrypoint, command, **kwargs): # noqa # Make sure the application is routable and uses the correct port # Done after the fact to let initial deploy settle before routing # traffic to the application - self._update_application_service(namespace, name, app_type, port, routable) + self._update_application_service(namespace, name, app_type, port, routable, annotations) def cleanup_release(self, namespace, controller, timeout): """ @@ -176,13 +177,15 @@ def _get_deploy_batches(self, steps, desired): return batches - def _update_application_service(self, namespace, name, app_type, port, routable=False): + def _update_application_service(self, namespace, name, app_type, port, routable=False, annotations={}): # noqa """Update application service with all the various required information""" service = self.get_service(namespace, namespace).json() old_service = service.copy() # in case anything fails for rollback try: # Update service information + for key, value in annotations.items(): + service['metadata']['annotations']['router.deis.io/%s' % key] = str(value) if routable: service['metadata']['labels']['router.deis.io/routable'] = 'true' else: