Skip to content

Commit 39c3bc9

Browse files
committed
feat(controller): add volume expand support
1 parent cde2c7f commit 39c3bc9

8 files changed

Lines changed: 120 additions & 54 deletions

File tree

rootfs/api/models/volume.py

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,11 @@ class Volume(UuidAuditedModel):
1818
size = models.CharField(max_length=128)
1919
path = models.JSONField(default=dict, blank=True)
2020

21-
class Meta:
22-
get_latest_by = 'created'
23-
unique_together = (('app', 'name'),)
24-
ordering = ['-created']
25-
26-
def __str__(self):
27-
return self.name
28-
2921
@transaction.atomic
3022
def save(self, *args, **kwargs):
3123
# Attach volume, updates k8s
3224
if self.created == self.updated:
33-
self.attach()
25+
self._create_pvc()
3426
# Save to DB
3527
return super(Volume, self).save(*args, **kwargs)
3628

@@ -39,10 +31,52 @@ def delete(self, *args, **kwargs):
3931
if self.path:
4032
raise DryccException("the volume is not unmounted")
4133
# Deatch volume, updates k8s
42-
self.detach()
34+
self._delete_pvc()
4335
# Delete from DB
4436
return super(Volume, self).delete(*args, **kwargs)
4537

38+
@transaction.atomic
39+
def expand(self, size):
40+
if unit_to_bytes(size) < unit_to_bytes(self.size):
41+
raise DryccException('Shrink volume is not supported.')
42+
self.size = size
43+
self.save()
44+
try:
45+
kwargs = {
46+
"size": self._get_size(self.size),
47+
"storage_class": settings.DRYCC_APP_STORAGE_CLASS,
48+
}
49+
self._scheduler.pvc.put(self.app.id, self.name, **kwargs)
50+
except KubeException as e:
51+
msg = 'There was a problem expand the volume ' \
52+
'{} for {}'.format(self.name, self.app_id)
53+
raise ServiceUnavailable(msg) from e
54+
55+
def log(self, message, level=logging.INFO):
56+
"""Logs a message in the context of this service.
57+
58+
This prefixes log messages with an application "tag" that the customized
59+
drycc-logspout will be on the lookout for. When it's seen, the message-- usually
60+
an application event of some sort like releasing or scaling, will be considered
61+
as "belonging" to the application instead of the controller and will be handled
62+
accordingly.
63+
"""
64+
logger.log(level, "[{}]: {}".format(self.id, message))
65+
66+
def to_measurements(self, timestamp: float):
67+
return [{
68+
"app_id": str(self.app_id),
69+
"user_id": str(self.owner_id),
70+
"name": self.name,
71+
"type": "volume",
72+
"unit": "bytes",
73+
"usage": unit_to_bytes(self.size),
74+
"timestamp": "%f" % timestamp
75+
}]
76+
77+
def __str__(self):
78+
return self.name
79+
4680
@staticmethod
4781
def _get_size(size):
4882
""" Format volume limit value """
@@ -53,7 +87,7 @@ def _get_size(size):
5387
size = size.upper() + "i"
5488
return size
5589

56-
def attach(self):
90+
def _create_pvc(self):
5791
try:
5892
self._scheduler.pvc.get(self.app.id, self.name)
5993
err = "Volume {} already exists in this namespace".format(self.name) # noqa
@@ -72,7 +106,7 @@ def attach(self):
72106
'{} for {}'.format(self.name, self.app_id)
73107
raise ServiceUnavailable(msg) from e
74108

75-
def detach(self):
109+
def _delete_pvc(self):
76110
try:
77111
# We raise an exception when a volume doesn't exist
78112
self._scheduler.pvc.get(self.app.id, self.name)
@@ -81,24 +115,7 @@ def detach(self):
81115
raise ServiceUnavailable("Could not delete volume {} for application \
82116
{}".format(self.name, self.app_id)) from e # noqa
83117

84-
def log(self, message, level=logging.INFO):
85-
"""Logs a message in the context of this service.
86-
87-
This prefixes log messages with an application "tag" that the customized
88-
drycc-logspout will be on the lookout for. When it's seen, the message-- usually
89-
an application event of some sort like releasing or scaling, will be considered
90-
as "belonging" to the application instead of the controller and will be handled
91-
accordingly.
92-
"""
93-
logger.log(level, "[{}]: {}".format(self.id, message))
94-
95-
def to_measurements(self, timestamp: float):
96-
return [{
97-
"app_id": str(self.app_id),
98-
"user_id": str(self.owner_id),
99-
"name": self.name,
100-
"type": "volume",
101-
"unit": "bytes",
102-
"usage": unit_to_bytes(self.size),
103-
"timestamp": "%f" % timestamp
104-
}]
118+
class Meta:
119+
get_latest_by = 'created'
120+
unique_together = (('app', 'name'),)
121+
ordering = ['-created']

rootfs/api/serializers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
TAGVAL_MATCH = re.compile(r'^(?:[a-zA-Z\d][-\.\w]{0,61})?[a-zA-Z\d]$')
3434
CONFIGKEY_MATCH = re.compile(r'^[a-z_]+[a-z0-9_]*$', re.IGNORECASE)
3535
TERMINATION_GRACE_PERIOD_MATCH = re.compile(r'^[0-9]*$')
36-
VOLUME_SIZE_MATCH = re.compile(r'^(?P<volume>([1-9][0-9]*[mgMG]))$', re.IGNORECASE)
36+
VOLUME_SIZE_MATCH = re.compile(r'^(?P<volume>([1-9][0-9]*[gG]))$', re.IGNORECASE)
3737
VOLUME_PATH = re.compile(r'^\/(\w+\/?)+$', re.IGNORECASE)
3838
METRIC_EVERY = re.compile(r'^[1-9][0-9]*m$')
3939

@@ -669,13 +669,13 @@ def validate_size(data):
669669
if not re.match(VOLUME_SIZE_MATCH, data):
670670
raise serializers.ValidationError(
671671
"Volume size limit format: <number><unit> or <number><unit>/<number><unit>, "
672-
"where unit = M or G")
672+
"where unit = G")
673673
max_volume = settings.KUBERNETES_LIMITS_MAX_VOLUME
674674
# The minimum limit memory is equal to the memory allocated by default
675675
min_volume = settings.KUBERNETES_LIMITS_MIN_VOLUME
676-
range_error = "Volume setting is not in allowed range: %sM~%sM" % (
676+
range_error = "Volume setting is not in allowed range: %sG~%sG" % (
677677
min_volume, max_volume)
678-
volume_size = int(data[:-1]) * 1024 if data.endswith("G") else int(data[:-1])
678+
volume_size = int(data[:-1])
679679
if volume_size < min_volume or volume_size > max_volume:
680680
raise serializers.ValidationError(range_error)
681681
return data.upper()

rootfs/api/settings/production.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,10 +353,10 @@
353353
KUBERNETES_LIMITS_MIN_MEMORY = 128
354354
# Max Memory limit, units are represented in Megabytes(M)
355355
KUBERNETES_LIMITS_MAX_MEMORY = 128 * 1024
356-
# Minimum Memory limit, units are represented in Megabytes(M)
357-
KUBERNETES_LIMITS_MIN_VOLUME = 128
358-
# Max Memory limit, units are represented in Megabytes(M)
359-
KUBERNETES_LIMITS_MAX_VOLUME = 128 * 1024
356+
# Minimum Stroage Volume limit, units are represented in Gigabytes(G)
357+
KUBERNETES_LIMITS_MIN_VOLUME = 1
358+
# Max Stroage Volume limit, units are represented in Gigabytes(G)
359+
KUBERNETES_LIMITS_MAX_VOLUME = 1024 * 16
360360

361361
# Default pod spec for application.
362362
KUBERNETES_POD_DEFAULT_RESOURCES = os.environ.get(

rootfs/api/tests/test_volume.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_volumecreate(self, mock_requests):
3939
response = self.client.post(
4040
'/v2/apps/{}/volumes'.format(app_id),
4141
data={
42-
'name': 'myvolume', 'size': '500M'
42+
'name': 'myvolume', 'size': '500G'
4343
}
4444
)
4545
self.assertEqual(response.status_code, 201, response.data)
@@ -53,7 +53,7 @@ def test_volumecreate(self, mock_requests):
5353
'owner': self.user.username,
5454
'app': app_id,
5555
'name': 'myvolume',
56-
'size': '500M'
56+
'size': '500G'
5757
}
5858
self.assertDictContainsSubset(expected, response.data)
5959

@@ -65,8 +65,8 @@ def test_volume_list_unmount(self, mock_requests):
6565
# create
6666
app_id = self.create_app()
6767
data = [
68-
{'name': 'myvolume1', 'size': '500M'},
69-
{'name': 'myvolume2', 'size': '500M'}
68+
{'name': 'myvolume1', 'size': '500G'},
69+
{'name': 'myvolume2', 'size': '500G'}
7070
]
7171
for _ in data:
7272
self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)
@@ -76,6 +76,36 @@ def test_volume_list_unmount(self, mock_requests):
7676
expected = [res['name'] for res in response.data['results']]
7777
self.assertEqual(sorted([_['name'] for _ in data]), sorted(expected))
7878

79+
def test_volume_expand(self, mock_requests):
80+
"""
81+
Test that volume is delete for a new app and that
82+
volume can be updated using a PATCH
83+
"""
84+
# create
85+
app_id = self.create_app()
86+
data = {'name': 'myvolume1', 'size': '500G'}
87+
self.client.post('/v2/apps/{}/volumes'.format(app_id), data=data)
88+
89+
# Put
90+
url = '/v2/apps/{app_id}/volumes/{volume}'.format(app_id=app_id,
91+
volume='myvolume1')
92+
response = self.client.put(url, {'name': 'myvolume1', 'size': '100G'})
93+
self.assertEqual(response.status_code, 400)
94+
95+
response = self.client.put(url, {'name': 'myvolume1', 'size': '1024G'})
96+
self.assertEqual(response.status_code, 200)
97+
# Fetch
98+
url = '/v2/apps/{app_id}/volumes'.format(app_id=app_id)
99+
response = self.client.get(url)
100+
expected = {
101+
'owner': self.user.username,
102+
'app': app_id,
103+
'name': 'myvolume1',
104+
'size': '1024G'
105+
}
106+
assert len(response.data["results"]) == 1
107+
self.assertDictContainsSubset(expected, response.data["results"][0])
108+
79109
def test_volume_delete(self, mock_requests):
80110
"""
81111
Test that volume is delete for a new app and that
@@ -84,8 +114,8 @@ def test_volume_delete(self, mock_requests):
84114
# create
85115
app_id = self.create_app()
86116
data = [
87-
{'name': 'myvolume1', 'size': '500M'},
88-
{'name': 'myvolume2', 'size': '500M'}
117+
{'name': 'myvolume1', 'size': '500G'},
118+
{'name': 'myvolume2', 'size': '500G'}
89119
]
90120
for _ in data:
91121
self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)
@@ -108,7 +138,7 @@ def test_volume_mount(self, mock_requests):
108138
# create
109139
app_id = self.create_app()
110140
data = [
111-
{'name': 'myvolume1', 'size': '500M'}
141+
{'name': 'myvolume1', 'size': '500G'}
112142
]
113143
for _ in data:
114144
self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)
@@ -125,7 +155,7 @@ def test_volume_unmount(self, mock_requests):
125155
# create
126156
app_id = self.create_app()
127157
data = [
128-
{'name': 'myvolume1', 'size': '500M'}
158+
{'name': 'myvolume1', 'size': '500G'}
129159
]
130160
for _ in data:
131161
self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)
@@ -183,8 +213,8 @@ def test_measure_volumes(self, *args, **kwargs):
183213
# create
184214
app_id = self.create_app()
185215
data = [
186-
{'name': 'myvolume1', 'size': '500M'},
187-
{'name': 'myvolume2', 'size': '500M'}
216+
{'name': 'myvolume1', 'size': '500G'},
217+
{'name': 'myvolume2', 'size': '500G'}
188218
]
189219
for _ in data:
190220
self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)

rootfs/api/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
url(r"^apps/(?P<id>{})/volumes/?$".format(settings.APP_URL_REGEX),
8080
views.AppVolumesViewSet.as_view({'get': 'list', 'post': 'create'})),
8181
url(r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
82-
views.AppVolumesViewSet.as_view({'delete': 'destroy'})),
82+
views.AppVolumesViewSet.as_view({'put': 'expand', 'delete': 'destroy'})),
8383
url(r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/path/?$".format(settings.APP_URL_REGEX),
8484
views.AppVolumeMountPathViewSet.as_view({'patch': 'path'})),
8585
# application resources

rootfs/api/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,14 @@ class AppVolumesViewSet(ReleasableViewSet):
680680
model = models.volume.Volume
681681
serializer_class = serializers.VolumeSerializer
682682

683+
def expand(self, request, **kwargs):
684+
volume = get_object_or_404(models.volume.Volume,
685+
app__id=self.kwargs['id'],
686+
name=self.kwargs['name'])
687+
volume.expand(request.data['size'])
688+
serializer = self.get_serializer(volume, many=False)
689+
return Response(serializer.data)
690+
683691
def destroy(self, request, **kwargs):
684692
volume = get_object_or_404(models.volume.Volume,
685693
app__id=self.kwargs['id'],

rootfs/scheduler/resources/pvc.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from scheduler.resources import Resource
23
from scheduler.exceptions import KubeHTTPException
34

@@ -64,6 +65,16 @@ def create(self, namespace, name, **kwargs):
6465
"create persistentvolumeclaim {}".format(namespace))
6566
return response
6667

68+
def put(self, namespace, name, **kwargs):
69+
url = self.api('/namespaces/{}/persistentvolumeclaims/{}', namespace,
70+
name)
71+
data = self.manifest(namespace, name, **kwargs)
72+
response = self.http_put(url, json=data)
73+
if self.unhealthy(response.status_code):
74+
self.log(namespace, 'template used: {}'.format(json.dumps(data, indent=4)), 'DEBUG') # noqa
75+
raise KubeHTTPException(response, 'update HorizontalPodAutoscaler "{}"', name)
76+
return response
77+
6778
def delete(self, namespace, name):
6879
"""
6980
Delete persistentvolumeclaim

rootfs/scheduler/resources/scale.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from scheduler.resources import Resource
23
from scheduler.exceptions import KubeHTTPException
34

@@ -31,10 +32,9 @@ def update(self, namespace, name, replicas, target):
3132
url = self.api("/namespaces/{}/{}/{}/scale", namespace, resource_type, name)
3233
response = self.http_put(url, json=manifest)
3334
if self.unhealthy(response.status_code):
35+
self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG') # noqa
3436
raise KubeHTTPException(
3537
response,
3638
'scale {} "{}" in Namespace "{}"', target['kind'], name, namespace
3739
)
38-
self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG') # noqa
39-
4040
return response

0 commit comments

Comments
 (0)