Skip to content

Commit 2a41942

Browse files
committed
feat(healthcheck): Add support to create healthcheck per proctype Requires workflow-cli#160
1 parent 37ea65c commit 2a41942

7 files changed

Lines changed: 181 additions & 68 deletions

File tree

rootfs/api/models/app.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,10 @@ def _scale_pods(self, scale_types):
409409
if port:
410410
envs['PORT'] = port
411411

412+
healthcheck = release.config.get_healthcheck().get('scale_type', {})
413+
if not healthcheck and scale_type in ['web', 'cmd']:
414+
healthcheck = release.config.get_healthcheck().get('web/cmd', {})
415+
412416
kwargs = {
413417
'memory': release.config.memory,
414418
'cpu': release.config.cpu,
@@ -419,7 +423,7 @@ def _scale_pods(self, scale_types):
419423
'replicas': replicas,
420424
'app_type': scale_type,
421425
'build_type': release.build.type,
422-
'healthcheck': release.config.healthcheck,
426+
'healthcheck': healthcheck,
423427
'routable': routable,
424428
'deploy_batches': batches,
425429
'deploy_timeout': deploy_timeout,
@@ -487,6 +491,10 @@ def deploy(self, release, force_deploy=False):
487491
if port:
488492
envs['PORT'] = port
489493

494+
healthcheck = release.config.get_healthcheck().get('scale_type', {})
495+
if not healthcheck and scale_type in ['web', 'cmd']:
496+
healthcheck = release.config.get_healthcheck().get('web/cmd', {})
497+
490498
deploys[scale_type] = {
491499
'memory': release.config.memory,
492500
'cpu': release.config.cpu,
@@ -498,7 +506,7 @@ def deploy(self, release, force_deploy=False):
498506
'version': version,
499507
'app_type': scale_type,
500508
'build_type': release.build.type,
501-
'healthcheck': release.config.healthcheck,
509+
'healthcheck': healthcheck,
502510
'routable': routable,
503511
'deploy_batches': batches,
504512
'deploy_timeout': deploy_timeout,

rootfs/api/models/config.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ def _migrate_legacy_healthcheck(self):
4646
success_threshold = int(self.values.get('HEALTHCHECK_SUCCESS_THRESHOLD', 1))
4747
failure_threshold = int(self.values.get('HEALTHCHECK_FAILURE_THRESHOLD', 3))
4848

49-
self.healthcheck['livenessProbe'] = {
49+
self.healthcheck['web/cmd'] = {}
50+
self.healthcheck['web/cmd']['livenessProbe'] = {
5051
'initialDelaySeconds': delay,
5152
'timeoutSeconds': timeout,
5253
'periodSeconds': period_seconds,
@@ -57,7 +58,7 @@ def _migrate_legacy_healthcheck(self):
5758
}
5859
}
5960

60-
self.healthcheck['readinessProbe'] = {
61+
self.healthcheck['web/cmd']['readinessProbe'] = {
6162
'initialDelaySeconds': delay,
6263
'timeoutSeconds': timeout,
6364
'periodSeconds': period_seconds,
@@ -71,6 +72,12 @@ def _migrate_legacy_healthcheck(self):
7172
# Unset all the old values
7273
self.values = {k: v for k, v in self.values.items() if not k.startswith('HEALTHCHECK_')}
7374

75+
def get_healthcheck(self):
76+
if('livenessProbe' in self.healthcheck.keys() or
77+
'readinessProbe' in self.healthcheck.keys()):
78+
return {'web/cmd': self.healthcheck}
79+
return self.healthcheck
80+
7481
def set_registry(self):
7582
# lower case all registry options for consistency
7683
self.registry = {key.lower(): value for key, value in self.registry.copy().items()}
@@ -105,6 +112,38 @@ def set_tags(self, previous_config):
105112

106113
raise DeisException(message)
107114

115+
def set_healthcheck(self, previous_config):
116+
data = getattr(previous_config, 'healthcheck', {}).copy()
117+
new_data = getattr(self, 'healthcheck', {}).copy()
118+
# update the config data for healthcheck if they are not
119+
# present for per proctype
120+
# TODO: This is required for backward compatibility and can be
121+
# removed in next major version change.
122+
if 'livenessProbe' in data.keys() or 'readinessProbe' in data.keys():
123+
data = {'web/cmd': data.copy()}
124+
if 'livenessProbe' in new_data.keys() or 'readinessProbe' in new_data.keys(): # noqa
125+
new_data = {'web/cmd': new_data.copy()}
126+
127+
# remove config keys if a null value is provided
128+
for key, value in new_data.items():
129+
if value is None:
130+
# error if unsetting non-existing key
131+
if key not in data:
132+
raise UnprocessableEntity('{} does not exist under {}'.format(key, 'healthcheck')) # noqa
133+
data.pop(key)
134+
else:
135+
for probeType, probe in value.items():
136+
if probe is None:
137+
# error if unsetting non-existing key
138+
if key not in data or probeType not in data[key].keys():
139+
raise UnprocessableEntity('{} does not exist under {}'.format(key, 'healthcheck')) # noqa
140+
data[key].pop(probeType)
141+
else:
142+
if key not in data:
143+
data[key] = {}
144+
data[key][probeType] = probe
145+
setattr(self, 'healthcheck', data)
146+
108147
def save(self, **kwargs):
109148
"""merge the old config with the new"""
110149
try:
@@ -116,7 +155,7 @@ def save(self, **kwargs):
116155
# usually means a totally new app
117156
previous_config = self.app.config_set.latest()
118157

119-
for attr in ['cpu', 'memory', 'tags', 'registry', 'values', 'healthcheck']:
158+
for attr in ['cpu', 'memory', 'tags', 'registry', 'values']:
120159
data = getattr(previous_config, attr, {}).copy()
121160
new_data = getattr(self, attr, {}).copy()
122161

@@ -131,6 +170,7 @@ def save(self, **kwargs):
131170
data[key] = value
132171
setattr(self, attr, data)
133172

173+
self.set_healthcheck(previous_config)
134174
self._migrate_legacy_healthcheck()
135175
self.set_registry()
136176
self.set_tags(previous_config)

rootfs/api/models/release.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -464,17 +464,17 @@ def save(self, *args, **kwargs): # noqa
464464
self.summary += ' and '
465465
self.summary += "{} {}".format(self.config.owner, changes)
466466

467-
# if the registry information changed, log the dict diff
467+
# if the healthcheck information changed, log the dict diff
468468
changes = []
469469
old_healthcheck = old_config.healthcheck if old_config else {}
470470
diff = dict_diff(self.config.healthcheck, old_healthcheck)
471471
# try to be as succinct as possible
472-
added = ', '.join(k for k in diff.get('added', {}))
473-
added = 'added healthcheck info ' + added if added else ''
474-
changed = ', '.join(k for k in diff.get('changed', {}))
475-
changed = 'changed healthcheck info ' + changed if changed else ''
476-
deleted = ', '.join(k for k in diff.get('deleted', {}))
477-
deleted = 'deleted healthcheck info ' + deleted if deleted else ''
472+
added = ', '.join(list(map(lambda x: 'default' if x == '' else x, [k for k in diff.get('added', {})]))) # noqa
473+
added = 'added healthcheck info for proc type ' + added if added else ''
474+
changed = ', '.join(list(map(lambda x: 'default' if x == '' else x, [k for k in diff.get('changed', {})]))) # noqa
475+
changed = 'changed healthcheck info for proc type ' + changed if changed else ''
476+
deleted = ', '.join(list(map(lambda x: 'default' if x == '' else x, [k for k in diff.get('deleted', {})]))) # noqa
477+
deleted = 'deleted healthcheck info for proc type ' + deleted if deleted else ''
478478
changes = ', '.join(i for i in (added, changed, deleted) if i)
479479
if changes:
480480
if self.summary:

rootfs/api/serializers.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -326,27 +326,29 @@ def validate_registry(self, data):
326326
return data
327327

328328
def validate_healthcheck(self, data):
329-
for key, value in data.items():
330-
if value is None:
329+
for procType, healthcheck in data.items():
330+
if healthcheck is None:
331331
continue
332+
for key, value in healthcheck.items():
333+
if value is None:
334+
continue
335+
if key not in ['livenessProbe', 'readinessProbe']:
336+
raise serializers.ValidationError(
337+
"Healthcheck keys must be either livenessProbe or readinessProbe")
338+
try:
339+
jsonschema.validate(value, PROBE_SCHEMA)
340+
except jsonschema.ValidationError as e:
341+
raise serializers.ValidationError(
342+
"could not validate {}: {}".format(value, e.message))
332343

333-
if key not in ['livenessProbe', 'readinessProbe']:
344+
# http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_probe
345+
# liveness only supports successThreshold=1, no other value
346+
# This is not in the schema since readiness supports other values
347+
threshold = jmespath.search('livenessProbe.successThreshold', healthcheck)
348+
if threshold is not None and threshold != 1:
334349
raise serializers.ValidationError(
335-
"Healthcheck keys must be either livenessProbe or readinessProbe")
336-
try:
337-
jsonschema.validate(value, PROBE_SCHEMA)
338-
except jsonschema.ValidationError as e:
339-
raise serializers.ValidationError(
340-
"could not validate {}: {}".format(value, e.message))
341-
342-
# http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_probe
343-
# liveness only supports successThreshold=1, no other value
344-
# This is not in the schema since readiness supports other values
345-
threshold = jmespath.search('livenessProbe.successThreshold', data)
346-
if threshold is not None and threshold != 1:
347-
raise serializers.ValidationError(
348-
'livenessProbe successThreshold can only be 1'
349-
)
350+
'livenessProbe successThreshold can only be 1'
351+
)
350352

351353
return data
352354

rootfs/api/tests/test_healthchecks.py

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -76,31 +76,65 @@ def test_config_healthchecks(self, mock_requests):
7676
Test that healthchecks can be applied
7777
"""
7878
app_id = self.create_app()
79-
readiness_probe = {'healthcheck': {'readinessProbe': {'httpGet': {'port': 5000}}}}
79+
readiness_probe = {'healthcheck': {'web/cmd': {'readinessProbe':
80+
{'httpGet': {'port': 5000}}}}}
8081

8182
response = self.client.post(
8283
'/v2/apps/{app_id}/config'.format(**locals()),
8384
readiness_probe)
8485
self.assertEqual(response.status_code, 201, response.data)
85-
self.assertIn('readinessProbe', response.data['healthcheck'])
86+
self.assertIn('readinessProbe', response.data['healthcheck']['web/cmd'])
8687
self.assertEqual(response.data['healthcheck'], readiness_probe['healthcheck'])
8788

88-
liveness_probe = {'healthcheck': {'livenessProbe':
89+
liveness_probe = {'healthcheck': {'web/cmd': {'livenessProbe':
8990
{'httpGet': {'port': 5000},
90-
'successThreshold': 1}}}
91+
'successThreshold': 1}}}}
9192
response = self.client.post(
9293
'/v2/apps/{app_id}/config'.format(**locals()),
9394
liveness_probe)
9495
self.assertEqual(response.status_code, 201, response.data)
95-
self.assertIn('livenessProbe', response.data['healthcheck'])
96+
self.assertIn('livenessProbe', response.data['healthcheck']['web/cmd'])
9697
self.assertEqual(
97-
response.data['healthcheck']['livenessProbe'],
98-
liveness_probe['healthcheck']['livenessProbe'])
98+
response.data['healthcheck']['web/cmd']['livenessProbe'],
99+
liveness_probe['healthcheck']['web/cmd']['livenessProbe'])
99100
# check that the readiness probe is still there too!
100-
self.assertIn('readinessProbe', response.data['healthcheck'])
101+
self.assertIn('readinessProbe', response.data['healthcheck']['web/cmd'])
101102
self.assertEqual(
102-
response.data['healthcheck']['readinessProbe'],
103-
readiness_probe['healthcheck']['readinessProbe'])
103+
response.data['healthcheck']['web/cmd']['readinessProbe'],
104+
readiness_probe['healthcheck']['web/cmd']['readinessProbe'])
105+
106+
# check that config fails if trying to unset non-existing healthcheck
107+
response = self.client.post(
108+
'/v2/apps/{app_id}/config'.format(**locals()),
109+
{'healthcheck': {'invalid_proctype': None}})
110+
self.assertEqual(response.status_code, 422, response.data)
111+
112+
# remove a probeType
113+
response = self.client.post(
114+
'/v2/apps/{app_id}/config'.format(**locals()),
115+
{'healthcheck': {'web/cmd': {'livenessProbe': None}}})
116+
self.assertEqual(response.status_code, 201, response.data)
117+
self.assertNotIn('livenessProbe', response.data['healthcheck']['web/cmd'])
118+
self.assertIn('readinessProbe', response.data['healthcheck']['web/cmd'])
119+
120+
# check that config fails if trying to unset non-existing probeType
121+
response = self.client.post(
122+
'/v2/apps/{app_id}/config'.format(**locals()),
123+
{'healthcheck': {'web/cmd': {'livenessProbe': None}}})
124+
self.assertEqual(response.status_code, 422, response.data)
125+
126+
# check that config fails if trying to unset non-existing probeType
127+
response = self.client.post(
128+
'/v2/apps/{app_id}/config'.format(**locals()),
129+
{'healthcheck': {'invalid_proctype': {'livenessProbe': None}}})
130+
self.assertEqual(response.status_code, 422, response.data)
131+
132+
# check that config fails if trying to unset non-existing probeType
133+
response = self.client.post(
134+
'/v2/apps/{app_id}/config'.format(**locals()),
135+
{'healthcheck': {'web/cmd': None}})
136+
self.assertEqual(response.status_code, 201, response.data)
137+
self.assertNotIn('web/cmd', response.data['healthcheck'])
104138

105139
# post a new build
106140
response = self.client.post(
@@ -115,29 +149,37 @@ def test_config_healthchecks_validations(self, mock_requests):
115149
"""
116150
app_id = self.create_app()
117151

152+
# Set a probe different from liveness/readiness
153+
response = self.client.post(
154+
'/v2/apps/{app_id}/config'.format(**locals()),
155+
{'healthcheck': json.dumps({'web/cmd': {'testProbe':
156+
{'httpGet': {'port': '50'}, 'initialDelaySeconds': "1"}}})}
157+
)
158+
self.assertEqual(response.status_code, 400, response.data)
159+
118160
# Set one of the values that require a numeric value to a string
119161
response = self.client.post(
120162
'/v2/apps/{app_id}/config'.format(**locals()),
121-
{'healthcheck': json.dumps({'livenessProbe':
122-
{'httpGet': {'port': '50'}, 'initialDelaySeconds': "t"}})}
163+
{'healthcheck': json.dumps({'web/cmd': {'livenessProbe':
164+
{'httpGet': {'port': '50'}, 'initialDelaySeconds': "t"}}})}
123165
)
124166
self.assertEqual(response.status_code, 400, response.data)
125167

126168
# Don't set one of the mandatory value
127169
response = self.client.post(
128170
'/v2/apps/{app_id}/config'.format(**locals()),
129-
{'healthcheck': json.dumps({'livenessProbe':
130-
{'httpGet': {'path': '/'}, 'initialDelaySeconds': 1}})}
171+
{'healthcheck': json.dumps({'web/cmd': {'livenessProbe':
172+
{'httpGet': {'path': '/'}, 'initialDelaySeconds': 1}}})}
131173
)
132174
self.assertEqual(response.status_code, 400, response.data)
133175

134176
# set liveness success threshold to a non-1 value
135177
# Don't set one of the mandatory value
136178
response = self.client.post(
137179
'/v2/apps/{app_id}/config'.format(**locals()),
138-
{'healthcheck': {'livenessProbe':
180+
{'healthcheck': {'web/cmd': {'livenessProbe':
139181
{'httpGet': {'path': '/', 'port': 5000},
140-
'successThreshold': 5}}}
182+
'successThreshold': 5}}}}
141183
)
142184
self.assertEqual(response.status_code, 400, response.data)
143185

@@ -158,7 +200,7 @@ def test_config_healthchecks_legacy(self, mock_requests):
158200
# this gets migrated to the new healtcheck format
159201
self.assertNotIn('HEALTHCHECK_URL', response.data['values'])
160202
# legacy defaults
161-
expected = {
203+
expected = {'web/cmd': {
162204
'livenessProbe': {
163205
'initialDelaySeconds': 50,
164206
'timeoutSeconds': 50,
@@ -179,6 +221,7 @@ def test_config_healthchecks_legacy(self, mock_requests):
179221
'path': '/health'
180222
}
181223
}
224+
}
182225
}
183226
actual = app.config_set.latest().healthcheck
184227
self.assertEqual(actual, expected)
@@ -198,7 +241,7 @@ def test_config_healthchecks_legacy(self, mock_requests):
198241
self.assertEqual(response.status_code, 201, response.data)
199242
# this gets migrated to the new healtcheck format
200243
self.assertNotIn('HEALTHCHECK_INITIAL_DELAY', response.data['values'])
201-
expected['livenessProbe'] = {
244+
expected['web/cmd']['livenessProbe'] = {
202245
'initialDelaySeconds': 25,
203246
'timeoutSeconds': 10,
204247
'periodSeconds': 5,
@@ -208,6 +251,6 @@ def test_config_healthchecks_legacy(self, mock_requests):
208251
'path': '/health'
209252
}
210253
}
211-
expected['readinessProbe'] = expected['livenessProbe']
254+
expected['web/cmd']['readinessProbe'] = expected['web/cmd']['livenessProbe']
212255
actual = app.config_set.latest().healthcheck
213256
self.assertEqual(expected, actual)

rootfs/scheduler/__init__.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -581,23 +581,22 @@ def _set_container(self, namespace, container_name, data, **kwargs):
581581
data["resources"]["limits"]["cpu"] = cpu.lower()
582582

583583
# add in healthchecks
584-
self._set_health_checks(data, env, kwargs)
585-
586-
def _set_health_checks(self, container, env, kwargs):
587-
if kwargs.get('routable', False):
588-
healthchecks = kwargs.get('healthcheck', None)
589-
if healthchecks:
590-
# check if a port is present. if not, auto-populate it
591-
# TODO: rip this out when we stop supporting deis config:set HEALTHCHECK_URL
592-
if (
593-
healthchecks.get('livenessProbe') is not None and
594-
healthchecks['livenessProbe'].get('httpGet') is not None and
595-
healthchecks['livenessProbe']['httpGet'].get('port') is None
596-
):
597-
healthchecks['livenessProbe']['httpGet']['port'] = env['PORT']
598-
container.update(healthchecks)
599-
else:
600-
self._default_readiness_probe(container, kwargs.get('build_type'), env.get('PORT', None)) # noqa
584+
self._set_health_checks(data, env, **kwargs)
585+
586+
def _set_health_checks(self, container, env, **kwargs):
587+
healthchecks = kwargs.get('healthcheck', None)
588+
if healthchecks:
589+
# check if a port is present. if not, auto-populate it
590+
# TODO: rip this out when we stop supporting deis config:set HEALTHCHECK_URL
591+
if (
592+
healthchecks.get('livenessProbe') is not None and
593+
healthchecks['livenessProbe'].get('httpGet') is not None and
594+
healthchecks['livenessProbe']['httpGet'].get('port') is None
595+
):
596+
healthchecks['livenessProbe']['httpGet']['port'] = env['PORT']
597+
container.update(healthchecks)
598+
elif kwargs.get('routable', False):
599+
self._default_readiness_probe(container, kwargs.get('build_type'), env.get('PORT', None)) # noqa
601600

602601
def _get_private_registry_config(self, registry, image):
603602
secret_name = settings.REGISTRY_SECRET_PREFIX

0 commit comments

Comments
 (0)