Skip to content
This repository was archived by the owner on May 6, 2020. It is now read-only.

Commit 26c78ee

Browse files
committed
feat(limits-cmd): accept new limits:set value type
Accept new cli format limits:set web=500m/2000m Value is map to requests/limits parameter in Kubernetes resource management. See workflow#562
1 parent c319d82 commit 26c78ee

5 files changed

Lines changed: 193 additions & 25 deletions

File tree

rootfs/api/serializers.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
# proc type name is alphanumeric
2020
# https://docs-v2.readthedocs.io/en/latest/using-workflow/process-types-and-the-procfile/#declaring-process-types
2121
PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z0-9]+)$')
22-
MEMLIMIT_MATCH = re.compile(r'^(?P<mem>[0-9]+(MB|KB|GB|[BKMG]))$', re.IGNORECASE)
23-
CPUSHARE_MATCH = re.compile(r'^(?P<cpu>[-+]?[0-9]*\.?[0-9]+[m]{0,1})$')
22+
MEMLIMIT_MATCH = re.compile(
23+
r'^(?P<mem>(([0-9]+(MB|KB|GB|[BKMG])|0)(/([0-9]+(MB|KB|GB|[BKMG])))?))$', re.IGNORECASE)
24+
CPUSHARE_MATCH = re.compile(
25+
r'^(?P<cpu>(([-+]?[0-9]*\.?[0-9]+[m]?)(/([-+]?[0-9]*\.?[0-9]+[m]?))?))$')
2426
TAGVAL_MATCH = re.compile(r'^(?:[a-zA-Z\d][-\.\w]{0,61})?[a-zA-Z\d]$')
2527
CONFIGKEY_MATCH = re.compile(r'^[a-z_]+[a-z0-9_]*$', re.IGNORECASE)
2628
PROBE_SCHEMA = {
@@ -273,7 +275,8 @@ def validate_memory(self, data):
273275

274276
if not re.match(MEMLIMIT_MATCH, str(value)):
275277
raise serializers.ValidationError(
276-
"Limit format: <number><unit>, where unit = B, K, M or G")
278+
"Memory limit format: <number><unit> or <number><unit>/<number><unit>, "
279+
"where unit = B, K, M or G")
277280

278281
return data
279282

@@ -287,7 +290,8 @@ def validate_cpu(self, data):
287290

288291
shares = re.match(CPUSHARE_MATCH, str(value))
289292
if not shares:
290-
raise serializers.ValidationError("CPU shares must be a numeric value")
293+
raise serializers.ValidationError(
294+
"CPU limit format: <value> or <value>/<value>, where value must be a numeric")
291295

292296
return data
293297

rootfs/api/tests/test_config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ def test_response_data_types_converted(self, mock_requests):
169169
body = {'cpu': json.dumps({'web': 'this will fail'})}
170170
response = self.client.post(url, body)
171171
self.assertEqual(response.status_code, 400, response.data)
172-
self.assertIn('CPU shares must be a numeric value', response.data['cpu'])
172+
self.assertIn(
173+
'CPU limit format: <value> or <value>/<value>, where value must be a numeric',
174+
response.data['cpu'])
173175

174176
def test_config_set_same_key(self, mock_requests):
175177
"""

rootfs/api/tests/test_limits.py

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from rest_framework.authtoken.models import Token
77

88
from api.serializers import MEMLIMIT_MATCH
9+
from api.serializers import CPUSHARE_MATCH
910
from api.tests import adapter, DeisTransactionTestCase
1011

1112

@@ -26,12 +27,31 @@ def tearDown(self):
2627

2728
def test_memlimit_regex(self, mock_requests):
2829
"""Tests the regex for unit format used by "deis limits:set --memory=<limit>"."""
30+
self.assertTrue(MEMLIMIT_MATCH.match("0/100MB"))
31+
self.assertTrue(MEMLIMIT_MATCH.match("200GB/100MB"))
2932
self.assertTrue(MEMLIMIT_MATCH.match("20MB"))
30-
self.assertFalse(MEMLIMIT_MATCH.match("20MK"))
3133
self.assertTrue(MEMLIMIT_MATCH.match("20gb"))
34+
self.assertTrue(MEMLIMIT_MATCH.match("0m"))
35+
self.assertFalse(MEMLIMIT_MATCH.match("20MK"))
36+
self.assertFalse(MEMLIMIT_MATCH.match("10"))
3237
self.assertFalse(MEMLIMIT_MATCH.match("20gK"))
38+
self.assertFalse(MEMLIMIT_MATCH.match("mb"))
3339

34-
def test_limit_memory(self, mock_requests):
40+
def test_cpushare_regex(self, mock_requests):
41+
"""Tests the regex for unit format used by "deis limits:set --cpu=<limit>"."""
42+
self.assertTrue(CPUSHARE_MATCH.match("0/2"))
43+
self.assertTrue(CPUSHARE_MATCH.match("500m/600m"))
44+
self.assertTrue(CPUSHARE_MATCH.match("0.5"))
45+
self.assertTrue(CPUSHARE_MATCH.match(".123"))
46+
self.assertTrue(CPUSHARE_MATCH.match("1.123"))
47+
self.assertTrue(CPUSHARE_MATCH.match("200m"))
48+
self.assertTrue(CPUSHARE_MATCH.match("0"))
49+
self.assertFalse(CPUSHARE_MATCH.match("20MK"))
50+
self.assertFalse(CPUSHARE_MATCH.match("20gK"))
51+
self.assertFalse(CPUSHARE_MATCH.match("m"))
52+
self.assertFalse(CPUSHARE_MATCH.match("."))
53+
54+
def test_request_limit_memory(self, mock_requests):
3555
"""
3656
Test that limit is auto-created for a new app and that
3757
limits can be updated using a PATCH
@@ -101,6 +121,38 @@ def test_limit_memory(self, mock_requests):
101121
self.assertIn('web', memory)
102122
self.assertEqual(memory['web'], '1G')
103123

124+
# add with requests/limits
125+
body = {'memory': json.dumps({'db': '1G/2G'})}
126+
response = self.client.post(url, body)
127+
self.assertEqual(response.status_code, 201, response.data)
128+
129+
# read the limit again
130+
response = self.client.get(url)
131+
self.assertEqual(response.status_code, 200, response.data)
132+
memory = response.data['memory']
133+
self.assertIn('worker', memory)
134+
self.assertEqual(memory['worker'], '512M')
135+
self.assertIn('web', memory)
136+
self.assertEqual(memory['web'], '1G')
137+
self.assertIn('db', memory)
138+
self.assertEqual(memory['db'], '1G/2G')
139+
140+
# replace one with requests/limits
141+
body = {'memory': json.dumps({'web': '3G/4G'})}
142+
response = self.client.post(url, body)
143+
self.assertEqual(response.status_code, 201, response.data)
144+
145+
# read the limit again
146+
response = self.client.get(url)
147+
self.assertEqual(response.status_code, 200, response.data)
148+
memory = response.data['memory']
149+
self.assertIn('worker', memory)
150+
self.assertEqual(memory['worker'], '512M')
151+
self.assertIn('web', memory)
152+
self.assertEqual(memory['web'], '3G/4G')
153+
self.assertIn('db', memory)
154+
self.assertEqual(memory['db'], '1G/2G')
155+
104156
# unset a value
105157
body = {'memory': json.dumps({'worker': None})}
106158
response = self.client.post(url, body)
@@ -129,9 +181,9 @@ def test_limit_memory(self, mock_requests):
129181
self.assertEqual(response.status_code, 405, response.data)
130182
return limit4
131183

132-
def test_limit_cpu(self, mock_requests):
184+
def test_request_limit_cpu(self, mock_requests):
133185
"""
134-
Test that CPU limits can be set
186+
Test that CPU requests/limits can be set
135187
"""
136188
app_id = self.create_app()
137189
url = '/v2/apps/{app_id}/config'.format(**locals())
@@ -150,7 +202,7 @@ def test_limit_cpu(self, mock_requests):
150202
self.assertEqual(response.status_code, 201, response.data)
151203
limit1 = response.data
152204

153-
# check memory limits
205+
# check cpu limits
154206
response = self.client.get(url)
155207
self.assertEqual(response.status_code, 200, response.data)
156208
self.assertIn('cpu', response.data)
@@ -181,6 +233,38 @@ def test_limit_cpu(self, mock_requests):
181233
self.assertIn('web', cpu)
182234
self.assertEqual(cpu['web'], '1024')
183235

236+
# add with requests/limits
237+
body = {'cpu': json.dumps({'db': '1/2'})}
238+
response = self.client.post(url, body)
239+
self.assertEqual(response.status_code, 201, response.data)
240+
241+
# read the limit again
242+
response = self.client.get(url)
243+
self.assertEqual(response.status_code, 200, response.data)
244+
cpu = response.data['cpu']
245+
self.assertIn('worker', cpu)
246+
self.assertEqual(cpu['worker'], '512m')
247+
self.assertIn('web', cpu)
248+
self.assertEqual(cpu['web'], '1024')
249+
self.assertIn('db', cpu)
250+
self.assertEqual(cpu['db'], '1/2')
251+
252+
# replace one with requests/limits
253+
body = {'cpu': json.dumps({'web': '300m/4000m'})}
254+
response = self.client.post(url, body)
255+
self.assertEqual(response.status_code, 201, response.data)
256+
257+
# read the limit again
258+
response = self.client.get(url)
259+
self.assertEqual(response.status_code, 200, response.data)
260+
cpu = response.data['cpu']
261+
self.assertIn('worker', cpu)
262+
self.assertEqual(cpu['worker'], '512m')
263+
self.assertIn('web', cpu)
264+
self.assertEqual(cpu['web'], '300m/4000m')
265+
self.assertIn('db', cpu)
266+
self.assertEqual(cpu['db'], '1/2')
267+
184268
# unset a value
185269
body = {'cpu': json.dumps({'worker': None})}
186270
response = self.client.post(url, body)

rootfs/scheduler/resources/pod.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import defaultdict
12
from datetime import datetime, timedelta
23
import operator
34
import os
@@ -172,9 +173,6 @@ def manifest(self, namespace, name, image, **kwargs):
172173

173174
def _set_container(self, namespace, container_name, data, **kwargs):
174175
"""Set app container information (env, healthcheck, etc) on a Pod"""
175-
app_type = kwargs.get('app_type')
176-
mem = kwargs.get('memory', {}).get(app_type)
177-
cpu = kwargs.get('cpu', {}).get(app_type)
178176
env = kwargs.get('envs', {})
179177

180178
# container name
@@ -222,23 +220,48 @@ def _set_container(self, namespace, container_name, data, **kwargs):
222220
# list sorted by dict key name
223221
data['env'].sort(key=operator.itemgetter('name'))
224222

223+
self._set_resources(data, kwargs)
224+
225+
self._set_health_checks(data, env, **kwargs)
226+
227+
def _set_resources(self, container, kwargs):
228+
""" Set CPU/memory resource management manifest """
229+
app_type = kwargs.get("app_type")
230+
mem = kwargs.get("memory", {}).get(app_type)
231+
cpu = kwargs.get("cpu", {}).get(app_type)
232+
225233
if mem or cpu:
226-
data["resources"] = {"limits": {}}
234+
resources = defaultdict(dict)
235+
236+
if mem:
237+
if "/" in mem:
238+
parts = mem.split("/")
239+
resources["requests"]["memory"] = self._format_memory(parts[0])
240+
resources["limits"]["memory"] = self._format_memory(parts[1])
241+
else:
242+
resources["limits"]["memory"] = self._format_memory(mem)
243+
244+
if cpu:
245+
# CPU needs to be defined as lower case
246+
if "/" in cpu:
247+
parts = cpu.split("/")
248+
resources["requests"]["cpu"] = parts[0].lower()
249+
resources["limits"]["cpu"] = parts[1].lower()
250+
else:
251+
resources["limits"]["cpu"] = cpu.lower()
252+
253+
if resources:
254+
container["resources"] = dict(resources)
227255

228-
if mem:
229-
if mem[-2:-1].isalpha() and mem[-1].isalpha():
230-
mem = mem[:-1]
256+
def _format_memory(self, mem):
257+
""" Format memory limit value """
258+
if mem[-2:-1].isalpha() and mem[-1].isalpha():
259+
mem = mem[:-1]
231260

261+
if mem[-1].isalpha():
232262
# memory needs to be upper cased (only first char)
233263
mem = mem.upper() + "i"
234-
data["resources"]["limits"]["memory"] = mem
235-
236-
if cpu:
237-
# CPU needs to be defined as lower case
238-
data["resources"]["limits"]["cpu"] = cpu.lower()
239-
240-
# add in healthchecks
241-
self._set_health_checks(data, env, **kwargs)
264+
return mem
242265

243266
def _set_health_checks(self, container, env, **kwargs):
244267
healthchecks = kwargs.get('healthcheck', None)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import unittest
2+
from scheduler.resources.pod import Pod
3+
4+
5+
class TestSchedulerPodResources(unittest.TestCase):
6+
def test_manifest_limits(self):
7+
cpu_cases = [
8+
{"app_type": "web", "cpu": {"cmd": "2"},
9+
"expected": None},
10+
{"app_type": "web", "cpu": {"web": "2"},
11+
"expected": {"limits": {"cpu": "2"}}},
12+
{"app_type": "web", "cpu": {"web": "0/3"},
13+
"expected": {"requests": {"cpu": "0"}, "limits": {"cpu": "3"}}},
14+
{"app_type": "web", "cpu": {"web": "4/5"},
15+
"expected": {"requests": {"cpu": "4"}, "limits": {"cpu": "5"}}},
16+
{"app_type": "web", "cpu": {"web": "400m/500m"},
17+
"expected": {"requests": {"cpu": "400m"}, "limits": {"cpu": "500m"}}},
18+
{"app_type": "web", "cpu": {"web": "0.6/0.7"},
19+
"expected": {"requests": {"cpu": "0.6"}, "limits": {"cpu": "0.7"}}},
20+
]
21+
22+
mem_cases = [
23+
{"app_type": "web", "memory": {"cmd": "2G"},
24+
"expected": None},
25+
{"app_type": "web", "memory": {"web": "200M"},
26+
"expected": {"limits": {"memory": "200Mi"}}},
27+
{"app_type": "web", "memory": {"web": "0/3G"},
28+
"expected": {"requests": {"memory": "0"}, "limits": {"memory": "3Gi"}}},
29+
{"app_type": "web", "memory": {"web": "400M/500MB"},
30+
"expected": {"requests": {"memory": "400Mi"}, "limits": {"memory": "500Mi"}}},
31+
]
32+
33+
for caze in cpu_cases:
34+
manifest = Pod("").manifest("",
35+
"",
36+
"",
37+
app_type=caze["app_type"],
38+
cpu=caze["cpu"])
39+
self._assert_resources(caze, manifest)
40+
41+
for caze in mem_cases:
42+
manifest = Pod("").manifest("",
43+
"",
44+
"",
45+
app_type=caze["app_type"],
46+
memory=caze["memory"])
47+
self._assert_resources(caze, manifest)
48+
49+
def _assert_resources(self, caze, manifest):
50+
resources_parent = manifest["spec"]["containers"][0]
51+
expected = caze["expected"]
52+
if expected:
53+
self.assertEqual(resources_parent["resources"], expected, caze)
54+
else:
55+
self.assertTrue("resources" not in resources_parent, caze)

0 commit comments

Comments
 (0)