Skip to content

Commit 0d83024

Browse files
authored
fix(deploy): add a global / per-app setting called PROCFILE_REMOVE_PROCS_ON_DEPLOY to allow turning off / on removal of processes on deploys (#1099)
By default this setting is On to keep backwards compatibility but will allow Deis operators and developers to turns this off. To set the global version of PROCFILE_REMOVE_PROCS_ON_DEPLOY give it an empty value to be considered off ref #1062
1 parent f4ef652 commit 0d83024

4 files changed

Lines changed: 273 additions & 4 deletions

File tree

rootfs/api/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class AlreadyExists(APIException):
1919
status_code = 409
2020

2121

22+
class Conflict(AlreadyExists):
23+
pass
24+
25+
2226
class UnprocessableEntity(APIException):
2327
status_code = 422
2428

rootfs/api/models/build.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from django.db import models
33
from jsonfield import JSONField
44

5-
from api.models import UuidAuditedModel, DeisException
5+
from api.models import UuidAuditedModel
6+
from api.exceptions import DeisException, Conflict
67

78
import logging
89
logger = logging.getLogger(__name__)
@@ -78,16 +79,55 @@ def create(self, user, *args, **kwargs):
7879
raise DeisException(str(e)) from e
7980

8081
def save(self, **kwargs):
81-
removed = {}
8282
previous_release = self.app.release_set.filter(failed=False).latest()
83-
if previous_release.build is not None:
83+
84+
if (
85+
settings.DEIS_DEPLOY_REJECT_IF_PROCFILE_MISSING is True and
86+
# previous release had a Procfile and the current one does not
87+
(
88+
previous_release.build is not None and
89+
len(previous_release.build.procfile) > 0 and
90+
len(self.procfile) == 0
91+
)
92+
):
93+
# Reject deployment
94+
raise Conflict(
95+
'Last deployment had a Procfile but is missing in this deploy. '
96+
'For a successful deployment provide a Procfile.'
97+
)
98+
99+
# See if processes are permitted to be removed
100+
remove_procs = (
101+
# If set to True then contents of Procfile does not affect the outcome
102+
settings.DEIS_DEPLOY_PROCFILE_MISSING_REMOVE is True or
103+
# previous release had a Procfile and the current one does as well
104+
(
105+
previous_release.build is not None and
106+
len(previous_release.build.procfile) > 0 and
107+
len(self.procfile) > 0
108+
)
109+
)
110+
111+
# spin down any proc type removed between the last procfile and the newest one
112+
if remove_procs and previous_release.build is not None:
113+
removed = {}
84114
for proc in previous_release.build.procfile:
85115
if proc not in self.procfile:
86116
# Scale proc type down to 0
87117
removed[proc] = 0
88118

89119
self.app.scale(self.owner, removed)
90120

121+
# make sure the latest build has procfile if the intent is to
122+
# allow empty Procfile without removals
123+
if (
124+
settings.DEIS_DEPLOY_PROCFILE_MISSING_REMOVE is False and
125+
previous_release.build is not None and
126+
len(previous_release.build.procfile) > 0 and
127+
len(self.procfile) == 0
128+
):
129+
self.procfile = previous_release.build.procfile
130+
91131
return super(Build, self).save(**kwargs)
92132

93133
def __str__(self):

rootfs/api/settings/production.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Django settings for the Deis project.
33
"""
4-
4+
from distutils.util import strtobool
55
import os.path
66
import tempfile
77

@@ -256,6 +256,29 @@
256256
SLUGRUNNER_IMAGE = os.environ.get('SLUGRUNNER_IMAGE_NAME', 'quay.io/deisci/slugrunner:canary') # noqa
257257
IMAGE_PULL_POLICY = os.environ.get('IMAGE_PULL_POLICY', "IfNotPresent") # noqa
258258

259+
# True, true, yes, y and more evaluate to True
260+
# False, false, no, n and more evaluate to False
261+
# https://docs.python.org/3/distutils/apiref.html?highlight=distutils.util#distutils.util.strtobool
262+
# see the above for all available options
263+
#
264+
# If a user deploys one build with a Procfile but then forgets to in the next one
265+
# then let that go through without scaling the missing process types down
266+
#
267+
# If the user has a Procfile in both deploys then processes are scaled up / down as per usual
268+
#
269+
# By default the process types are scaled down unless this setting is turned on
270+
DEIS_DEPLOY_PROCFILE_MISSING_REMOVE = bool(strtobool(os.environ.get('DEIS_DEPLOY_PROCFILE_MISSING_REMOVE', 'true'))) # noqa
271+
272+
# True, true, yes, y and more evaluate to True
273+
# False, false, no, n and more evaluate to False
274+
# https://docs.python.org/3/distutils/apiref.html?highlight=distutils.util#distutils.util.strtobool
275+
# see the above for all available options
276+
#
277+
# If a previous deploy had a Procfile but then the following deploy has no Procfile then it will
278+
# result in a 406 - Not Acceptable
279+
# Has priority over DEIS_DEPLOY_PROCFILE_MISSING_REMOVE
280+
DEIS_DEPLOY_REJECT_IF_PROCFILE_MISSING = bool(strtobool(os.environ.get('DEIS_DEPLOY_REJECT_IF_PROCFILE_MISSING', 'false'))) # noqa
281+
259282
# Define a global default on how many pods to bring up and then
260283
# take down sequentially during a deploy
261284
# Defaults to None, the default is to deploy to as many nodes as

rootfs/api/tests/test_build.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.contrib.auth.models import User
99
from django.core.cache import cache
1010
from django.conf import settings
11+
from django.test.utils import override_settings
1112
from unittest import mock
1213
from rest_framework.authtoken.models import Token
1314

@@ -235,6 +236,207 @@ def test_build_default_containers(self, mock_requests):
235236
response = self.client.post(url, body)
236237
self.assertEqual(response.status_code, 201, response.data)
237238

239+
@override_settings(DEIS_DEPLOY_PROCFILE_MISSING_REMOVE=True)
240+
def test_build_forgotten_procfile(self, mock_requests):
241+
"""
242+
Test that when a user first posts a build with a Procfile
243+
and then later without it that missing process are actually
244+
scaled down
245+
"""
246+
# start with a new app
247+
app_id = self.create_app()
248+
249+
# post a new build with procfile
250+
url = "/v2/apps/{app_id}/builds".format(**locals())
251+
body = {
252+
'image': 'autotest/example',
253+
'sha': 'a'*40,
254+
'procfile': {
255+
'web': 'node server.js',
256+
'worker': 'node worker.js'
257+
}
258+
}
259+
response = self.client.post(url, body)
260+
self.assertEqual(response.status_code, 201, response.data)
261+
262+
# scale worker
263+
url = "/v2/apps/{app_id}/scale".format(**locals())
264+
body = {'worker': 1}
265+
response = self.client.post(url, body)
266+
self.assertEqual(response.status_code, 204, response.data)
267+
268+
# verify worker
269+
url = "/v2/apps/{app_id}/pods/worker".format(**locals())
270+
response = self.client.get(url)
271+
self.assertEqual(response.status_code, 200, response.data)
272+
self.assertEqual(len(response.data['results']), 1)
273+
container = response.data['results'][0]
274+
self.assertEqual(container['type'], 'worker')
275+
self.assertEqual(container['release'], 'v2')
276+
# pod name is auto generated so use regex
277+
self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
278+
279+
# do another deploy for this time forget Procfile
280+
url = "/v2/apps/{app_id}/builds".format(**locals())
281+
body = {'image': 'autotest/example'}
282+
response = self.client.post(url, body)
283+
self.assertEqual(response.status_code, 201, response.data)
284+
285+
# verify worker is not there
286+
url = "/v2/apps/{app_id}/pods/worker".format(**locals())
287+
response = self.client.get(url)
288+
self.assertEqual(response.status_code, 200, response.data)
289+
self.assertEqual(len(response.data['results']), 0)
290+
291+
# verify web is still there
292+
url = "/v2/apps/{app_id}/pods/web".format(**locals())
293+
response = self.client.get(url)
294+
self.assertEqual(response.status_code, 200, response.data)
295+
self.assertEqual(len(response.data['results']), 0)
296+
297+
# look at the app structure
298+
url = "/v2/apps/{app_id}".format(**locals())
299+
response = self.client.get(url)
300+
self.assertEqual(response.status_code, 200, response.data)
301+
self.assertEqual(response.json()['structure'], {'web': 0, 'worker': 0})
302+
303+
@override_settings(DEIS_DEPLOY_PROCFILE_MISSING_REMOVE=False)
304+
def test_build_no_remove_process(self, mock_requests):
305+
"""
306+
Specifically test PROCFILE_REMOVE_PROCS_ON_DEPLOY being turned off
307+
and a user posting a new build without a Procfile that was previously
308+
applied
309+
"""
310+
# start with a new app
311+
app_id = self.create_app()
312+
313+
# post a new build with procfile
314+
url = "/v2/apps/{app_id}/builds".format(**locals())
315+
body = {
316+
'image': 'autotest/example',
317+
'sha': 'a'*40,
318+
'procfile': {
319+
'web': 'node server.js',
320+
'worker': 'node worker.js'
321+
}
322+
}
323+
response = self.client.post(url, body)
324+
self.assertEqual(response.status_code, 201, response.data)
325+
326+
# scale worker
327+
url = "/v2/apps/{app_id}/scale".format(**locals())
328+
body = {'worker': 1}
329+
response = self.client.post(url, body)
330+
self.assertEqual(response.status_code, 204, response.data)
331+
332+
# verify worker
333+
url = "/v2/apps/{app_id}/pods/worker".format(**locals())
334+
response = self.client.get(url)
335+
self.assertEqual(response.status_code, 200, response.data)
336+
self.assertEqual(len(response.data['results']), 1)
337+
container = response.data['results'][0]
338+
self.assertEqual(container['type'], 'worker')
339+
self.assertEqual(container['release'], 'v2')
340+
# pod name is auto generated so use regex
341+
self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
342+
343+
# do another deploy for this time forget Procfile
344+
url = "/v2/apps/{app_id}/builds".format(**locals())
345+
body = {'image': 'autotest/example'}
346+
response = self.client.post(url, body)
347+
self.assertEqual(response.status_code, 201, response.data)
348+
349+
# verify worker is still there
350+
url = "/v2/apps/{app_id}/pods/worker".format(**locals())
351+
response = self.client.get(url)
352+
self.assertEqual(response.status_code, 200, response.data)
353+
self.assertEqual(len(response.data['results']), 1)
354+
container = response.data['results'][0]
355+
self.assertEqual(container['type'], 'worker')
356+
self.assertEqual(container['release'], 'v3')
357+
# pod name is auto generated so use regex
358+
self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
359+
360+
# verify web is still there
361+
url = "/v2/apps/{app_id}/pods/web".format(**locals())
362+
response = self.client.get(url)
363+
self.assertEqual(response.status_code, 200, response.data)
364+
self.assertEqual(len(response.data['results']), 1)
365+
container = response.data['results'][0]
366+
self.assertEqual(container['type'], 'web')
367+
self.assertEqual(container['release'], 'v3')
368+
# pod name is auto generated so use regex
369+
self.assertRegex(container['name'], app_id + '-web-[0-9]{8,10}-[a-z0-9]{5}')
370+
371+
# look at the app structure
372+
url = "/v2/apps/{app_id}".format(**locals())
373+
response = self.client.get(url)
374+
self.assertEqual(response.status_code, 200, response.data)
375+
self.assertEqual(response.json()['structure'], {'web': 1, 'worker': 1})
376+
377+
# scale worker to make sure no info was lost
378+
url = "/v2/apps/{app_id}/scale".format(**locals())
379+
body = {'worker': 2} # bump from 1 to 2
380+
response = self.client.post(url, body)
381+
self.assertEqual(response.status_code, 204, response.data)
382+
383+
# verify worker info
384+
url = "/v2/apps/{app_id}/pods/worker".format(**locals())
385+
response = self.client.get(url)
386+
self.assertEqual(response.status_code, 200, response.data)
387+
self.assertEqual(len(response.data['results']), 2)
388+
container = response.data['results'][0]
389+
self.assertEqual(container['type'], 'worker')
390+
self.assertEqual(container['release'], 'v3')
391+
# pod name is auto generated so use regex
392+
self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
393+
394+
@override_settings(DEIS_DEPLOY_REJECT_IF_PROCFILE_MISSING=True)
395+
def test_build_forgotten_procfile_reject(self, mock_requests):
396+
"""
397+
Test that when a user first posts a build with a Procfile
398+
and then later without it that missing process are actually
399+
scaled down
400+
"""
401+
# start with a new app
402+
app_id = self.create_app()
403+
404+
# post a new build with procfile
405+
url = "/v2/apps/{app_id}/builds".format(**locals())
406+
body = {
407+
'image': 'autotest/example',
408+
'sha': 'a'*40,
409+
'procfile': {
410+
'web': 'node server.js',
411+
'worker': 'node worker.js'
412+
}
413+
}
414+
response = self.client.post(url, body)
415+
self.assertEqual(response.status_code, 201, response.data)
416+
417+
# scale worker
418+
url = "/v2/apps/{app_id}/scale".format(**locals())
419+
body = {'worker': 1}
420+
response = self.client.post(url, body)
421+
self.assertEqual(response.status_code, 204, response.data)
422+
423+
# verify worker
424+
url = "/v2/apps/{app_id}/pods/worker".format(**locals())
425+
response = self.client.get(url)
426+
self.assertEqual(response.status_code, 200, response.data)
427+
self.assertEqual(len(response.data['results']), 1)
428+
container = response.data['results'][0]
429+
self.assertEqual(container['type'], 'worker')
430+
self.assertEqual(container['release'], 'v2')
431+
# pod name is auto generated so use regex
432+
self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
433+
434+
# do another deploy for this time forget Procfile
435+
url = "/v2/apps/{app_id}/builds".format(**locals())
436+
body = {'image': 'autotest/example'}
437+
response = self.client.post(url, body)
438+
self.assertEqual(response.status_code, 409, response.data)
439+
238440
def test_build_str(self, mock_requests):
239441
"""Test the text representation of a build."""
240442
app_id = self.create_app()

0 commit comments

Comments
 (0)