Skip to content

Commit b01033b

Browse files
author
Gabriel Monroy
committed
feat(controller): add endpoint and infrastructure for limiting memory/cpu
1 parent a3db96e commit b01033b

9 files changed

Lines changed: 549 additions & 20 deletions

File tree

controller/api/models.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ def __str__(self):
134134
def create(self, *args, **kwargs):
135135
config = Config.objects.create(owner=self.owner, app=self, values={})
136136
build = Build.objects.create(owner=self.owner, app=self, image=settings.DEFAULT_BUILD)
137-
Release.objects.create(version=1, owner=self.owner, app=self, config=config, build=build)
137+
limit = Limit.objects.create(owner=self.owner, app=self, memory={}, cpu={})
138+
Release.objects.create(version=1, owner=self.owner, app=self,
139+
config=config, build=build, limit=limit)
138140

139141
def delete(self, *args, **kwargs):
140142
for c in self.container_set.all():
@@ -301,10 +303,13 @@ def _command_announceable(self):
301303
@transition(field=state, source=INITIALIZED, target=CREATED)
302304
def create(self):
303305
image = self.release.image
306+
kwargs = {'memory': self.release.limit.memory,
307+
'cpu': self.release.limit.cpu}
304308
self._scheduler.create(name=self._job_id,
305309
image=image,
306310
command=self._command,
307-
use_announcer=self._command_announceable())
311+
use_announcer=self._command_announceable(),
312+
**kwargs)
308313

309314
@close_db_connections
310315
@transition(field=state,
@@ -327,10 +332,13 @@ def deploy(self, release):
327332
new_job_id = self._job_id
328333
image = self.release.image
329334
c_type = self.type
335+
kwargs = {'memory': self.release.limit.memory,
336+
'cpu': self.release.limit.cpu}
330337
self._scheduler.create(name=new_job_id,
331338
image=image,
332339
command=self._command.format(**locals()),
333-
use_announcer=self._command_announceable())
340+
use_announcer=self._command_announceable(),
341+
**kwargs)
334342
self._scheduler.start(new_job_id, self._command_announceable())
335343
# destroy old container
336344
self._scheduler.destroy(old_job_id, self._command_announceable())
@@ -426,6 +434,27 @@ def __str__(self):
426434
return "{}-{}".format(self.app.id, self.uuid[:7])
427435

428436

437+
@python_2_unicode_compatible
438+
class Limit(UuidAuditedModel):
439+
"""
440+
Set of resource limits applied by the scheduler
441+
during runtime execution of the Application.
442+
"""
443+
444+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
445+
app = models.ForeignKey('App')
446+
memory = JSONField(default='{}', blank=True)
447+
cpu = JSONField(default='{}', blank=True)
448+
449+
class Meta:
450+
get_latest_by = 'created'
451+
ordering = ['-created']
452+
unique_together = (('app', 'uuid'),)
453+
454+
def __str__(self):
455+
return "{}-{}".format(self.app.id, self.uuid[:7])
456+
457+
429458
@python_2_unicode_compatible
430459
class Release(UuidAuditedModel):
431460
"""
@@ -441,6 +470,7 @@ class Release(UuidAuditedModel):
441470

442471
config = models.ForeignKey('Config')
443472
build = models.ForeignKey('Build')
473+
limit = models.ForeignKey('Limit', null=True)
444474
# NOTE: image contains combined build + config, ready to run
445475
image = models.CharField(max_length=256, default=settings.DEFAULT_BUILD)
446476

@@ -452,7 +482,8 @@ class Meta:
452482
def __str__(self):
453483
return "{0}-v{1}".format(self.app.id, self.version)
454484

455-
def new(self, user, config=None, build=None, summary=None, source_version='latest'):
485+
def new(self, user, config=None, build=None, limit=None,
486+
summary=None, source_version='latest'):
456487
"""
457488
Create a new application release using the provided Build and Config
458489
on behalf of a user.
@@ -463,6 +494,8 @@ def new(self, user, config=None, build=None, summary=None, source_version='lates
463494
config = self.config
464495
if not build:
465496
build = self.build
497+
if not limit:
498+
limit = self.limit
466499
# always create a release off the latest image
467500
source_image = '{}:{}'.format(build.image, source_version)
468501
# construct fully-qualified target image
@@ -472,8 +505,8 @@ def new(self, user, config=None, build=None, summary=None, source_version='lates
472505
target_image = '{}'.format(self.app.id)
473506
# create new release and auto-increment version
474507
release = Release.objects.create(
475-
owner=user, app=self.app, config=config,
476-
build=build, version=new_version, image=target_image, summary=summary)
508+
owner=user, app=self.app, config=config, build=build, limit=limit,
509+
version=new_version, image=target_image, summary=summary)
477510
# IOW, this image did not come from the builder
478511
if not build.sha:
479512
# we assume that the image is not present on our registry,
@@ -507,12 +540,14 @@ def previous(self):
507540
prev_release = None
508541
return prev_release
509542

510-
def save(self, *args, **kwargs):
543+
def save(self, *args, **kwargs): # noqa
511544
if not self.summary:
512545
self.summary = ''
513546
prev_release = self.previous()
514547
# compare this build to the previous build
515548
old_build = prev_release.build if prev_release else None
549+
old_config = prev_release.config if prev_release else None
550+
old_limit = prev_release.limit if prev_release else None
516551
# if the build changed, log it and who pushed it
517552
if self.version == 1:
518553
self.summary += "{} created initial release".format(self.app.owner)
@@ -521,10 +556,8 @@ def save(self, *args, **kwargs):
521556
self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7])
522557
else:
523558
self.summary += "{} deployed {}".format(self.build.owner, self.build.image)
524-
# compare this config to the previous config
525-
old_config = prev_release.config if prev_release else None
526559
# if the config data changed, log the dict diff
527-
if self.config != old_config:
560+
elif self.config != old_config:
528561
dict1 = self.config.values
529562
dict2 = old_config.values if old_config else {}
530563
diff = dict_diff(dict1, dict2)
@@ -540,11 +573,25 @@ def save(self, *args, **kwargs):
540573
if self.summary:
541574
self.summary += ' and '
542575
self.summary += "{} {}".format(self.config.owner, changes)
543-
if not self.summary:
544-
if self.version == 1:
545-
self.summary = "{} created the initial release".format(self.owner)
546-
else:
547-
self.summary = "{} changed nothing".format(self.owner)
576+
# if the limit changes, log the dict diff
577+
elif self.limit != old_limit:
578+
changes = []
579+
old_mem = old_limit.memory if old_limit else {}
580+
diff = dict_diff(self.limit.memory, old_mem)
581+
if diff.get('added') or diff.get('changed') or diff.get('deleted'):
582+
changes.append('memory')
583+
old_cpu = old_limit.cpu if old_limit else {}
584+
diff = dict_diff(self.limit.cpu, old_cpu)
585+
if diff.get('added') or diff.get('changed') or diff.get('deleted'):
586+
changes.append('cpu')
587+
if changes:
588+
changes = 'changed limits for '+', '.join(changes)
589+
self.summary += "{} {}".format(self.config.owner, changes)
590+
if not self.summary:
591+
if self.version == 1:
592+
self.summary = "{} created the initial release".format(self.owner)
593+
else:
594+
self.summary = "{} changed nothing".format(self.owner)
548595
super(Release, self).save(*args, **kwargs)
549596

550597

controller/api/serializers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
from api import utils
1515

1616

17+
PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z]+)')
18+
MEMLIMIT_MATCH = re.compile(r'^(?P<mem>[0-9]+[BbKkMmGg])$')
19+
CPUSHARE_MATCH = re.compile(r'^(?P<cpu>[0-9]+)$')
20+
21+
1722
class OwnerSlugRelatedField(serializers.SlugRelatedField):
1823
"""Filter queries by owner as well as slug_field."""
1924

@@ -102,6 +107,51 @@ class Meta:
102107
read_only_fields = ('uuid', 'created', 'updated')
103108

104109

110+
class LimitSerializer(serializers.ModelSerializer):
111+
"""Serialize a :class:`~api.models.Limit` model."""
112+
113+
owner = serializers.Field(source='owner.username')
114+
app = serializers.SlugRelatedField(slug_field='id')
115+
memory = serializers.ModelField(
116+
model_field=models.Limit()._meta.get_field('memory'), required=False)
117+
cpu = serializers.ModelField(
118+
model_field=models.Limit()._meta.get_field('cpu'), required=False)
119+
120+
class Meta:
121+
"""Metadata options for a :class:`LimitSerializer`."""
122+
model = models.Limit
123+
read_only_fields = ('uuid', 'created', 'updated')
124+
125+
def validate_memory(self, attrs, source):
126+
for k, v in attrs.get(source, {}).items():
127+
if v is None: # use NoneType to unset a value
128+
continue
129+
if not re.match(PROCTYPE_MATCH, k):
130+
raise serializers.ValidationError("Process types can only contain [a-z]")
131+
if not re.match(MEMLIMIT_MATCH, str(v)):
132+
raise serializers.ValidationError(
133+
"Limit format: <number><unit>, where unit = B, K, M or G")
134+
return attrs
135+
136+
def validate_cpu(self, attrs, source):
137+
for k, v in attrs.get(source, {}).items():
138+
if v is None: # use NoneType to unset a value
139+
continue
140+
if not re.match(PROCTYPE_MATCH, k):
141+
raise serializers.ValidationError("Process types can only contain [a-z]")
142+
shares = re.match(CPUSHARE_MATCH, str(v))
143+
if not shares:
144+
raise serializers.ValidationError("CPU shares must be an integer")
145+
for v in shares.groupdict().values():
146+
try:
147+
i = int(v)
148+
except ValueError:
149+
raise serializers.ValidationError("CPU shares must be an integer")
150+
if i > 1024 or i < 0:
151+
raise serializers.ValidationError("CPU shares must be between 0 and 1024")
152+
return attrs
153+
154+
105155
class ReleaseSerializer(serializers.ModelSerializer):
106156
"""Serialize a :class:`~api.models.Release` model."""
107157

0 commit comments

Comments
 (0)