Repository: deis/deis

Issue:
fix(controller): don't allow user to call deis config multipl times
Create item in etcd, which will block ability to call config more then one time until configuration will be applied.
Fixing: #4746



Discussion:
When complete, this feature should be ported forward to deis/workflow as well.


Resolving patch:
diff --git a/controller/api/views.py b/controller/api/views.py
--- a/controller/api/views.py
+++ b/controller/api/views.py
@@ -1,6 +1,10 @@
 """
 RESTful view classes for presenting Deis API objects.
 """
+
+import os.path
+import tempfile
+
 from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.contrib.auth.models import User
@@ -15,6 +19,7 @@
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.authtoken.models import Token
+from simpleflock import SimpleFlock
 
 from api import authentication, models, permissions, serializers, viewsets
 
@@ -277,6 +282,18 @@ class ConfigViewSet(ReleasableViewSet):
     model = models.Config
     serializer_class = serializers.ConfigSerializer
 
+    def create(self, request, **kwargs):
+        # Guard against overlapping config changes, using a filesystem lock so that
+        # multiple controller processes can be coordinated.
+        # Use a tempfile such as "/tmp/violet-valkyrie-config".
+        lockfile = os.path.join(tempfile.gettempdir(), kwargs['id'] + '-config')
+        try:
+            with SimpleFlock(lockfile, timeout=5):
+                return super(ConfigViewSet, self).create(request, **kwargs)
+        except IOError as err:
+            msg = "Config changes already in progress.\n{}".format(err)
+            return Response(status=status.HTTP_409_CONFLICT, data={'error': msg})
+
     def post_save(self, config):
         release = config.app.release_set.latest()
         self.release = release.new(self.request.user, config=config, build=release.build)


Test patch:
diff --git a/controller/api/tests/test_config.py b/controller/api/tests/test_config.py
--- a/controller/api/tests/test_config.py
+++ b/controller/api/tests/test_config.py
@@ -9,13 +9,16 @@
 
 import json
 import logging
+import os.path
 import requests
+import tempfile
 
 from django.contrib.auth.models import User
 from django.test import TransactionTestCase
 import etcd
 import mock
 from rest_framework.authtoken.models import Token
+from simpleflock import SimpleFlock
 
 import api.exceptions
 from api.models import App, Config
@@ -133,6 +136,42 @@ def test_config(self):
         self.assertEqual(response.status_code, 405)
         return config5
 
+    @mock.patch('requests.post', mock_status_ok)
+    def test_overlapping_config(self):
+        """
+        Test that config won't be created if a similar operation
+        is in progress for that app.
+        """
+        url = '/v1/apps'
+        response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']
+        # check to see that an initial/empty config was created
+        url = "/v1/apps/{app_id}/config".format(**locals())
+        response = self.client.get(url,
+                                   HTTP_AUTHORIZATION='token {}'.format(self.token))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('values', response.data)
+        self.assertEqual(response.data['values'], {})
+        config1 = response.data
+        # create the lockfile as though a "deis config:set" were in progress
+        lockfile = os.path.join(tempfile.gettempdir(), app_id + "-config")
+        with SimpleFlock(lockfile):
+            # set an initial config value
+            body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+            response = self.client.post(url, json.dumps(body), content_type='application/json',
+                                        HTTP_AUTHORIZATION='token {}'.format(self.token))
+            self.assertEqual(response.status_code, 409)
+            self.assertNotIn('values', response.data)
+            self.assertNotIn('uuid', response.data)
+        # read the config
+        response = self.client.get(url,
+                                   HTTP_AUTHORIZATION='token {}'.format(self.token))
+        self.assertEqual(response.status_code, 200)
+        config2 = response.data
+        self.assertEqual(config1, config2)
+        self.assertNotIn('NEW_URL1', response.data['values'])
+
     @mock.patch('requests.post', mock_status_ok)
     def test_response_data(self):
         """Test that the serialized response contains only relevant data."""