Skip to content

Commit c3c2494

Browse files
committed
feat(*): add Deployments support behind a feature flag
This adds Deployment object support (replaces ReplicationController) when a feature flag is enabled. This can be done by either settings a global ENV Var on the Controller or setting a config per app. The variable is DEIS_KUBERNETES_DEPLOYMENT=1 A few new features (Deployments only) have been introduced or specifically expose Deployment functionalities: * KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT (global and per app setting) specifies how many revisions (ReplicaSets) to keep * Stop concurrent deployments (triggered by push, pull, config:set and so on) If Deployments is enabled on an existing app then a whole new copy will be spun up (behind the scenes) as Deployment and then the old ReplicationController is spun down. This is a one way conversion. Note that pod naming has changed due to how Deployments adds a hash to the name, among other things.
1 parent 40f1600 commit c3c2494

8 files changed

Lines changed: 566 additions & 61 deletions

File tree

rootfs/api/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def custom_exception_handler(exc, context):
3939
set_rollback()
4040
return Response({'detail': 'Server Error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
4141

42-
# log a few different types of exception instead of use APIException
42+
# log a few different types of exception instead of using APIException
4343
if isinstance(exc, (DeisException, ServiceUnavailable, HealthcheckException)):
4444
logging.exception(exc.__cause__, exc_info=exc)
4545

rootfs/api/models/app.py

Lines changed: 75 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,16 @@ def __str__(self):
121121
def _get_job_id(self, container_type):
122122
app = self.id
123123
release = self.release_set.latest()
124+
125+
# see if there is a global or app specific setting to specify Deployments usage
126+
deployments = bool(release.config.values.get('DEIS_KUBERNETES_DEPLOYMENTS', settings.DEIS_KUBERNETES_DEPLOYMENTS)) # noqa
127+
128+
# deployments does not need version in the job
129+
if deployments:
130+
return "{app}-{container_type}".format(**locals())
131+
124132
version = "v{}".format(release.version)
125-
job_id = "{app}-{version}-{container_type}".format(**locals())
126-
return job_id
133+
return "{app}-{version}-{container_type}".format(**locals())
127134

128135
def _get_command(self, container_type):
129136
try:
@@ -237,35 +244,48 @@ def delete(self, *args, **kwargs):
237244

238245
def restart(self, **kwargs): # noqa
239246
"""
240-
Restart found pods by deleting them (RC will recreate).
241-
Wait until they are all drained away and RC has gotten to a good state
247+
Restart found pods by deleting them (RC / Deployment will recreate).
248+
Wait until they are all drained away and RC / Deployment has gotten to a good state
242249
"""
243250
try:
244-
# Resolve single pod name if short form (worker-asdfg) is passed
245-
if 'name' in kwargs and kwargs['name'].count('-') == 1:
246-
if 'release' not in kwargs or kwargs['release'] is None:
247-
release = self.release_set.latest()
248-
else:
249-
release = self.release_set.get(version=kwargs['release'])
251+
if kwargs.get('release', None) is None:
252+
release = self.release_set.latest()
253+
else:
254+
release = self.release_set.get(version=kwargs['release'])
250255

251-
version = "v{}".format(release.version)
252-
kwargs['name'] = '{}-{}-{}'.format(kwargs['id'], version, kwargs['name'])
256+
# see if there is a global or app specific setting to specify Deployments usage
257+
deployments = bool(release.config.values.get('DEIS_KUBERNETES_DEPLOYMENTS', settings.DEIS_KUBERNETES_DEPLOYMENTS)) # noqa
253258

254-
# Iterate over RCs to get total desired count if not a single item
259+
if deployments:
260+
# Resolve single pod name if short form (cmd-1269180282-1nyfz) is passed
261+
if 'name' in kwargs and kwargs['name'].count('-') == 2:
262+
kwargs['name'] = '{}-{}'.format(kwargs['id'], kwargs['name'])
263+
else:
264+
# Resolve single pod name if short form (worker-asdfg) is passed
265+
if 'name' in kwargs and kwargs['name'].count('-') == 1:
266+
version = "v{}".format(release.version)
267+
kwargs['name'] = '{}-{}-{}'.format(kwargs['id'], version, kwargs['name'])
268+
269+
# Iterate over RCs / RSs to get total desired count if not a single item
255270
desired = 1
256271
if 'name' not in kwargs:
257272
desired = 0
258273
labels = self._scheduler_filter(**kwargs)
259-
controllers = self._scheduler.get_rcs(kwargs['id'], labels=labels).json()['items']
260-
for controller in controllers:
274+
# fetch RS (which represent Deployments) / RCs
275+
if deployments:
276+
controllers = self._scheduler.get_replicasets(kwargs['id'], labels=labels)
277+
else:
278+
controllers = self._scheduler.get_rcs(kwargs['id'], labels=labels)
279+
280+
for controller in controllers.json()['items']:
261281
desired += controller['spec']['replicas']
262282
except KubeException:
263283
# Nothing was found
264284
return []
265285

266286
try:
267287
for pod in self.list_pods(**kwargs):
268-
# This function verifies the delete. Gives pod 30 seconds
288+
# This function verifies the delete
269289
self._scheduler.delete_pod(self.id, pod['name'])
270290
except Exception as e:
271291
err = "warning, some pods failed to stop:\n{}".format(str(e))
@@ -380,6 +400,13 @@ def scale(self, user, structure): # noqa
380400
def _scale_pods(self, scale_types):
381401
release = self.release_set.latest()
382402
envs = release.config.values
403+
404+
# see if the app config has deploy batch preference, otherwise use global
405+
batches = release.config.values.get('DEIS_DEPLOY_BATCHES', settings.DEIS_DEPLOY_BATCHES)
406+
407+
# see if there is a global or app specific setting to specify Deployments usage
408+
deployments = bool(envs.get('DEIS_KUBERNETES_DEPLOYMENTS', settings.DEIS_KUBERNETES_DEPLOYMENTS)) # noqa
409+
383410
for scale_type, replicas in scale_types.items():
384411
# only web / cmd are routable
385412
# http://docs.deis.io/en/latest/using_deis/process-types/#web-vs-cmd-process-types
@@ -400,7 +427,10 @@ def _scale_pods(self, scale_types):
400427
'app_type': scale_type,
401428
'build_type': release.build.type,
402429
'healthcheck': release.config.healthcheck,
403-
'routable': routable
430+
'routable': routable,
431+
'deployments': deployments,
432+
'deploy_batches': batches,
433+
'deploy_timeout': 120, # 2 minutes
404434
}
405435

406436
command = self._get_command(scale_type)
@@ -417,8 +447,12 @@ def _scale_pods(self, scale_types):
417447
self.log(err, logging.ERROR)
418448
raise ServiceUnavailable(err) from e
419449

420-
def deploy(self, release):
421-
"""Deploy a new release to this application"""
450+
def deploy(self, release, force_deploy=False):
451+
"""
452+
Deploy a new release to this application
453+
454+
force_deploy can be used when a deployment is broken, such as for Rollback
455+
"""
422456
if release.build is None:
423457
raise DeisException('No build associated with this release')
424458

@@ -432,6 +466,11 @@ def deploy(self, release):
432466
# see if the app config has deploy batch preference, otherwise use global
433467
batches = release.config.values.get('DEIS_DEPLOY_BATCHES', settings.DEIS_DEPLOY_BATCHES)
434468

469+
# see if there is a global or app specific setting to specify Deployments usage
470+
deployments = bool(release.config.values.get('DEIS_KUBERNETES_DEPLOYMENTS', settings.DEIS_KUBERNETES_DEPLOYMENTS)) # noqa
471+
472+
deployment_history = release.config.values.get('KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT', settings.KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT) # noqa
473+
435474
# deploy application to k8s. Also handles initial scaling
436475
deploys = {}
437476
envs = release.config.values
@@ -457,14 +496,23 @@ def deploy(self, release):
457496
'build_type': release.build.type,
458497
'healthcheck': release.config.healthcheck,
459498
'routable': routable,
460-
'deploy_batches': batches
499+
'deploy_batches': batches,
500+
'deploy_timeout': 120, # 2 minutes
501+
'deployment_history_limit': deployment_history,
502+
'deployments': deployments,
503+
'release_summary': release.summary
461504
}
462505

463506
# Sort deploys so routable comes first
464507
deploys = OrderedDict(sorted(deploys.items(), key=lambda d: d[1].get('routable')))
465508

466509
for scale_type, kwargs in deploys.items():
467510
try:
511+
# Is there an existing deployment in progress?
512+
name = self._get_job_id(scale_type)
513+
if not force_deploy and release.deployment_in_progress(self.id, name):
514+
raise AlreadyExists('Deployment for {} is already in progress'.format(name))
515+
468516
self._scheduler.deploy(
469517
namespace=self.id,
470518
name=self._get_job_id(scale_type),
@@ -484,8 +532,8 @@ def deploy(self, release):
484532
self.log(err, logging.ERROR)
485533
raise ServiceUnavailable(err) from e
486534

487-
# cleanup old releases from kubernetes
488-
release.cleanup_old()
535+
# cleanup old release objects from kubernetes
536+
release.cleanup_old(deployments)
489537

490538
def _default_structure(self, release):
491539
"""Scale to default structure based on release type"""
@@ -668,8 +716,9 @@ def list_pods(self, *args, **kwargs):
668716

669717
data = []
670718
for p in pods:
719+
labels = p['metadata']['labels']
671720
# specifically ignore run pods
672-
if p['metadata']['labels']['type'] == 'run':
721+
if labels['type'] == 'run':
673722
continue
674723

675724
state = str(self._scheduler.pod_state(p))
@@ -685,8 +734,8 @@ def list_pods(self, *args, **kwargs):
685734
item = Pod()
686735
item['name'] = p['metadata']['name']
687736
item['state'] = state
688-
item['release'] = p['metadata']['labels']['version']
689-
item['type'] = p['metadata']['labels']['type']
737+
item['release'] = labels['version']
738+
item['type'] = labels['type']
690739
if 'startTime' in p['status']:
691740
started = p['status']['startTime']
692741
else:
@@ -697,7 +746,6 @@ def list_pods(self, *args, **kwargs):
697746

698747
# sorting so latest start date is first
699748
data.sort(key=lambda x: x['started'], reverse=True)
700-
701749
return data
702750
except KubeHTTPException as e:
703751
pass
@@ -707,7 +755,7 @@ def list_pods(self, *args, **kwargs):
707755
raise ServiceUnavailable(err) from e
708756

709757
def _scheduler_filter(self, **kwargs):
710-
labels = {'app': self.id}
758+
labels = {'app': self.id, 'heritage': 'deis'}
711759

712760
# always supply a version, either latest or a specific one
713761
if 'release' not in kwargs or kwargs['release'] is None:

rootfs/api/models/release.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def rollback(self, user, version=None):
225225
)
226226

227227
if self.build is not None:
228-
self.app.deploy(new_release)
228+
self.app.deploy(new_release, force_deploy=True)
229229
return new_release
230230
except Exception as e:
231231
if 'new_release' in locals():
@@ -246,11 +246,11 @@ def delete(self, *args, **kwargs):
246246
finally:
247247
super(Release, self).delete(*args, **kwargs)
248248

249-
def cleanup_old(self):
249+
def cleanup_old(self, deployment=False):
250250
"""Cleanup all but the latest release from Kubernetes"""
251251
latest_version = 'v{}'.format(self.version)
252252
self.app.log(
253-
'Cleaning up RCS for releases older than {} (latest)'.format(latest_version),
253+
'Cleaning up RCs for releases older than {} (latest)'.format(latest_version),
254254
level=logging.DEBUG
255255
)
256256

@@ -282,7 +282,7 @@ def cleanup_old(self):
282282
labels = {
283283
'heritage': 'deis',
284284
'app': self.app.id,
285-
'type': 'env'
285+
'type': 'env',
286286
}
287287
secrets = self._scheduler.get_secrets(self.app.id, labels=labels).json()
288288
for secret in secrets['items']:
@@ -307,18 +307,58 @@ def cleanup_old(self):
307307

308308
self._scheduler.delete_pod(self.app.id, pod['metadata']['name'])
309309

310+
if deployment:
311+
self._cleanup_deployment_secrets_and_configs(self.app.id)
312+
313+
def _cleanup_deployment_secrets_and_configs(self, namespace):
314+
"""
315+
Clean up any environment secrets (and in the future ConfigMaps) that
316+
are tied to a release Deployments no longer track
317+
318+
This is done by checking the available ReplicaSets and only removing
319+
objects not attached to anything. This will allow releases done outside
320+
of Deis Controller
321+
"""
322+
323+
# Find all ReplicaSets
324+
versions = []
325+
labels = {'heritage': 'deis', 'app': namespace}
326+
replicasets = self._scheduler.get_replicasets(namespace, labels=labels).json()
327+
for replicaset in replicasets['items']:
328+
if (
329+
'version' not in replicaset['metadata']['labels'] or
330+
replicaset['metadata']['labels']['version'] in versions
331+
):
332+
continue
333+
334+
versions.append(replicaset['metadata']['labels']['version'])
335+
336+
# find all env secrets not owned by any replicaset
337+
labels = {
338+
'heritage': 'deis',
339+
'app': namespace,
340+
'type': 'env',
341+
# http://kubernetes.io/docs/user-guide/labels/#set-based-requirement
342+
'version__notin': versions
343+
}
344+
self.app.log('Cleaning up orphaned env var secrets for application {}'.format(namespace), level=logging.DEBUG) # noqa
345+
secrets = self._scheduler.get_secrets(namespace, labels=labels).json()
346+
for secret in secrets['items']:
347+
self._scheduler.delete_secret(namespace, secret['metadata']['name'])
348+
310349
def _delete_release_in_scheduler(self, namespace, version):
311350
"""
312-
Deletes a specific release in k8s
351+
Deletes a specific release in k8s based on ReplicationController
313352
314-
Scale RCs to 0 then delete RCs and the version specific
353+
Scale RCs to 0 then delete RCs / Deployments and the version specific
315354
secret that container the env var
316355
"""
317356
labels = {
318357
'heritage': 'deis',
319358
'app': namespace,
320-
'version': 'v{}'.format(version)
359+
'version': version
321360
}
361+
322362
controllers = self._scheduler.get_rcs(namespace, labels=labels).json()
323363
for controller in controllers['items']:
324364
self._scheduler.cleanup_release(namespace, controller)
@@ -330,6 +370,20 @@ def _delete_release_in_scheduler(self, namespace, version):
330370
except KubeHTTPException:
331371
pass
332372

373+
def deployment_in_progress(self, namespace, name):
374+
# see if there is a global or app specific setting to specify Deployments usage
375+
deployments = bool(self.config.values.get('DEIS_KUBERNETES_DEPLOYMENTS', settings.DEIS_KUBERNETES_DEPLOYMENTS)) # noqa
376+
if not deployments:
377+
return False
378+
379+
try:
380+
ready, _ = self._scheduler.are_deployment_replicas_ready(namespace, name)
381+
return not ready
382+
except KubeHTTPException as e:
383+
# Deployment doesn't exist
384+
if e.response.status_code == 404:
385+
return False
386+
333387
def save(self, *args, **kwargs): # noqa
334388
if not self.summary:
335389
self.summary = ''

rootfs/api/settings/production.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@
265265
# Can also be overwritten on per app basis if desired
266266
DEIS_DEPLOY_BATCHES = os.environ.get('DEIS_DEPLOY_BATCHES', None)
267267

268+
# If the k8s Deployments object should be used instead of ReplicationController
269+
DEIS_KUBERNETES_DEPLOYMENTS = bool(os.environ.get('DEIS_KUBERNETES_DEPLOYMENTS', False))
270+
271+
KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT = os.environ.get('KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT', None) # noqa
272+
268273
# How long k8s waits for a pod to finish work after a SIGTERM before sending SIGKILL
269274
KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS = int(os.environ.get('KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS', 30)) # noqa
270275

rootfs/api/tests/test_build.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,10 @@ def test_build_default_containers(self, mock_requests):
123123
self.assertEqual(container['type'], 'cmd')
124124
self.assertEqual(container['release'], 'v2')
125125
# pod name is auto generated so use regex
126-
self.assertRegex(container['name'], app_id + '-v2-cmd-[a-z0-9]{5}')
126+
if settings.DEIS_KUBERNETES_DEPLOYMENTS:
127+
self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
128+
else:
129+
self.assertRegex(container['name'], app_id + '-v2-cmd-[a-z0-9]{5}')
127130

128131
# start with a new app
129132
url = '/v2/apps'
@@ -148,7 +151,10 @@ def test_build_default_containers(self, mock_requests):
148151
self.assertEqual(container['type'], 'cmd')
149152
self.assertEqual(container['release'], 'v2')
150153
# pod name is auto generated so use regex
151-
self.assertRegex(container['name'], app_id + '-v2-cmd-[a-z0-9]{5}')
154+
if settings.DEIS_KUBERNETES_DEPLOYMENTS:
155+
self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
156+
else:
157+
self.assertRegex(container['name'], app_id + '-v2-cmd-[a-z0-9]{5}')
152158

153159
# start with a new app
154160
url = '/v2/apps'
@@ -177,7 +183,10 @@ def test_build_default_containers(self, mock_requests):
177183
self.assertEqual(container['type'], 'cmd')
178184
self.assertEqual(container['release'], 'v2')
179185
# pod name is auto generated so use regex
180-
self.assertRegex(container['name'], app_id + '-v2-cmd-[a-z0-9]{5}')
186+
if settings.DEIS_KUBERNETES_DEPLOYMENTS:
187+
self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
188+
else:
189+
self.assertRegex(container['name'], app_id + '-v2-cmd-[a-z0-9]{5}')
181190

182191
# start with a new app
183192
url = '/v2/apps'
@@ -206,7 +215,10 @@ def test_build_default_containers(self, mock_requests):
206215
self.assertEqual(container['type'], 'web')
207216
self.assertEqual(container['release'], 'v2')
208217
# pod name is auto generated so use regex
209-
self.assertRegex(container['name'], app_id + '-v2-web-[a-z0-9]{5}')
218+
if settings.DEIS_KUBERNETES_DEPLOYMENTS:
219+
self.assertRegex(container['name'], app_id + '-web-[0-9]{8,10}-[a-z0-9]{5}')
220+
else:
221+
self.assertRegex(container['name'], app_id + '-v2-web-[a-z0-9]{5}')
210222

211223
def test_build_str(self, mock_requests):
212224
"""Test the text representation of a build."""

0 commit comments

Comments
 (0)