Skip to content

Commit 1786cda

Browse files
committed
feat(controller): volume support nfs
1 parent 2708c94 commit 1786cda

9 files changed

Lines changed: 201 additions & 93 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.10 on 2024-02-23 03:23
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0001_initial'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='volume',
15+
name='parameters',
16+
field=models.JSONField(default=dict),
17+
),
18+
migrations.AddField(
19+
model_name='volume',
20+
name='type',
21+
field=models.CharField(choices=[('csi', 'container storage interface'), ('nfs', 'network file system')], default='csi'),
22+
),
23+
migrations.AlterField(
24+
model_name='volume',
25+
name='path',
26+
field=models.JSONField(default=dict),
27+
),
28+
migrations.AlterField(
29+
model_name='volume',
30+
name='size',
31+
field=models.CharField(default='0G', max_length=128),
32+
),
33+
]

rootfs/api/models/app.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,14 +1076,14 @@ def _get_volumes_and_mounts(self, process_type, volumes):
10761076
k8s_volumes, k8s_volume_mounts = [], []
10771077
if volumes:
10781078
for volume in volumes:
1079-
k8s_volumes.append({
1080-
"name": volume.name,
1081-
"claimName": volume.name
1082-
})
1083-
k8s_volume_mounts.append({
1084-
"name": volume.name,
1085-
"mount_path": volume.path.get(process_type)
1086-
})
1079+
k8s_volume = {"name": volume.name}
1080+
if volume.type == "csi":
1081+
k8s_volume.update({"persistentVolumeClaim": {"claimName": volume.name}})
1082+
else:
1083+
k8s_volume.update(volume.parameters)
1084+
k8s_volumes.append(k8s_volume)
1085+
k8s_volume_mounts.append(
1086+
{"name": volume.name, "mountPath": volume.path.get(process_type)})
10871087
k8s_volumes.extend(json.loads(settings.KUBERNETES_POD_DEFAULT_VOLUMES))
10881088
k8s_volume_mounts.extend(json.loads(settings.KUBERNETES_POD_DEFAULT_VOLUME_MOUNTS))
10891089
return k8s_volumes, k8s_volume_mounts

rootfs/api/models/volume.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,22 @@
1212

1313

1414
class Volume(UuidAuditedModel):
15+
TYPE_CHOICES = (
16+
("csi", "container storage interface"),
17+
("nfs", "network file system"),
18+
)
1519
owner = models.ForeignKey(User, on_delete=models.PROTECT)
1620
app = models.ForeignKey('App', on_delete=models.CASCADE)
1721
name = models.CharField(max_length=63, validators=[validate_label])
18-
size = models.CharField(max_length=128)
19-
path = models.JSONField(default=dict, blank=True)
22+
size = models.CharField(default='0G', max_length=128)
23+
path = models.JSONField(default=dict)
24+
type = models.CharField(default=TYPE_CHOICES[0][0], choices=TYPE_CHOICES)
25+
parameters = models.JSONField(default=dict)
2026

2127
@transaction.atomic
2228
def save(self, *args, **kwargs):
2329
# Attach volume, updates k8s
24-
if self.created == self.updated:
30+
if self.type == "csi" and self.created == self.updated:
2531
self._create_pvc()
2632
# Save to DB
2733
return super(Volume, self).save(*args, **kwargs)
@@ -31,26 +37,30 @@ def delete(self, *args, **kwargs):
3137
if self.path:
3238
raise DryccException("the volume is not unmounted")
3339
# Deatch volume, updates k8s
34-
self._delete_pvc()
40+
if self.type == "csi":
41+
self._delete_pvc()
3542
# Delete from DB
3643
return super(Volume, self).delete(*args, **kwargs)
3744

3845
@transaction.atomic
3946
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.patch(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
47+
if self.type == "csi":
48+
if unit_to_bytes(size) < unit_to_bytes(self.size):
49+
raise DryccException('Shrink volume is not supported.')
50+
self.size = size
51+
self.save()
52+
try:
53+
kwargs = {
54+
"size": self._format_size(self.size),
55+
"storage_class": settings.DRYCC_APP_STORAGE_CLASS,
56+
}
57+
self._scheduler.pvc.patch(self.app.id, self.name, **kwargs)
58+
except KubeException as e:
59+
msg = 'There was a problem expand the volume ' \
60+
'{} for {}'.format(self.name, self.app_id)
61+
raise ServiceUnavailable(msg) from e
62+
else:
63+
raise DryccException(f'{self.type} volume is not support expand.')
5464

5565
def log(self, message, level=logging.INFO):
5666
"""Logs a message in the context of this service.
@@ -70,15 +80,15 @@ def to_measurements(self, timestamp: float):
7080
"name": self.name,
7181
"type": "volume",
7282
"unit": "bytes",
73-
"usage": unit_to_bytes(self.size),
83+
"usage": unit_to_bytes(self.size) if self.type == "csi" else 0,
7484
"timestamp": int(timestamp)
7585
}]
7686

7787
def __str__(self):
7888
return self.name
7989

8090
@staticmethod
81-
def _get_size(size):
91+
def _format_size(size):
8292
""" Format volume limit value """
8393
if size[-2:-1].isalpha() and size[-1].isalpha():
8494
size = size[:-1]
@@ -97,7 +107,7 @@ def _create_pvc(self):
97107
logger.info(e)
98108
try:
99109
kwargs = {
100-
"size": self._get_size(self.size),
110+
"size": self._format_size(self.size),
101111
"storage_class": settings.DRYCC_APP_STORAGE_CLASS,
102112
}
103113
self._scheduler.pvc.create(self.app.id, self.name, **kwargs)

rootfs/api/schemas/volumes.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
SCHEMA = {
2+
"$schema": "http://json-schema.org/schema#",
3+
"type": "object",
4+
"properties": {
5+
"nfs": {
6+
"type": "object",
7+
"properties": {
8+
"server": {"type": "string"},
9+
"path": {"type": "string"},
10+
"readOnly": {"type": "boolean"},
11+
},
12+
"required": ["server", "path", "readOnly"],
13+
},
14+
},
15+
}

rootfs/api/serializers.py

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,50 @@
1818
from api import models
1919
from api.exceptions import DryccException
2020
from api.schemas.rules import SCHEMA as RULES_SCHEMA
21+
from api.schemas.volumes import SCHEMA as VOLUMES_SCHEMA
2122
from api.schemas.autoscale import SCHEMA as AUTOSCALE_SCHEMA
2223
from api.schemas.healthcheck import SCHEMA as HEALTHCHECK_SCHEMA
2324

2425

2526
User = get_user_model()
2627
logger = logging.getLogger(__name__)
2728
SERVICE_PROTOCOL_MATCH = re.compile(r'^(TCP|UDP|SCTP)$')
28-
SERVICE_PROTOCOL_MISMATCH_MSG = "the service protocol only supports TCP, UDP, and SCTP"
29+
SERVICE_PROTOCOL_MISMATCH_MSG = (
30+
"the service protocol only supports: %s" % SERVICE_PROTOCOL_MATCH.pattern)
2931
GATEWAY_PROTOCOL_MATCH = re.compile(r'^(HTTP|HTTPS|TCP|TLS|UDP)$')
30-
GATEWAY_PROTOCOL_MISMATCH_MSG = "the gateway protocol only supports HTTP, HTTPS, TCP, TLS and UDP"
32+
GATEWAY_PROTOCOL_MISMATCH_MSG = (
33+
"the gateway protocol only supports: %s" % GATEWAY_PROTOCOL_MATCH.pattern)
3134
ROUTE_PROTOCOL_MATCH = re.compile(r'^(HTTPRoute|TCPRoute|UDPRoute|TLSRoute)$')
32-
ROUTE_PROTOCOL_MISMATCH_MSG = "the route kind only supports HTTPRoute, TCPRoute, UDPRoute, and TLSRoute" # noqa
35+
ROUTE_PROTOCOL_MISMATCH_MSG = (
36+
"the route kind only supports: %s" % ROUTE_PROTOCOL_MATCH.pattern)
3337
PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)(?<!-canary)$')
34-
PROCTYPE_MISMATCH_MSG = "Process types can only contain lowercase alphanumeric characters"
38+
PROCTYPE_MISMATCH_MSG = "Process types can only supports: %s" % PROCTYPE_MATCH.pattern
3539
MEMLIMIT_MATCH = re.compile(r'^(?P<mem>([1-9][0-9]*[mgMG]))$', re.IGNORECASE)
40+
MEMLIMIT_MISMATCH_MSG = (
41+
"Memory limit format: <number><unit>, "
42+
"where unit = M or G"
43+
)
3644
CPUSHARE_MATCH = re.compile(r'^(?P<cpu>([-+]?[1-9][0-9]*[m]?))$')
45+
CPUSHARE_MISMATCH_MSG = "CPU limit format: <value>, where value must be a numeric"
3746
TAGVAL_MATCH = re.compile(r'^(?:[a-zA-Z\d][-\.\w]{0,61})?[a-zA-Z\d]$')
3847
CONFIGKEY_MATCH = re.compile(r'^[a-z_]+[a-z0-9_]*$', re.IGNORECASE)
48+
CONFIGKEY_MISMATCH_MSG = (
49+
"Config keys must start with a letter or underscore and "
50+
"only contain [A-z0-9_]"
51+
)
52+
3953
TERMINATION_GRACE_PERIOD_MATCH = re.compile(r'^[0-9]*$')
54+
TERMINATION_GRACE_PERIOD_MISMATCH_MSG = (
55+
"Termination Grace Period format: %s" % TERMINATION_GRACE_PERIOD_MATCH.pattern)
56+
VOLUME_TYPE_MATCH = re.compile(r'^(csi|nfs)$')
57+
VOLUME_TYPE_MISMATCH_MSG = "Volume type pattern: %s" % VOLUME_TYPE_MATCH.pattern
4058
VOLUME_SIZE_MATCH = re.compile(r'^(?P<volume>([1-9][0-9]*[gG]))$', re.IGNORECASE)
41-
VOLUME_PATH = re.compile(r'^\/(\w+\/?)+$', re.IGNORECASE)
42-
METRIC_EVERY = re.compile(r'^[1-9][0-9]*m$')
59+
VOLUME_SIZE_MISMATCH_MSG = (
60+
"Volume size limit format: <number><unit> or <number><unit>/<number><unit>, "
61+
"where unit = G, range: %sG~%sG"
62+
) % (settings.KUBERNETES_LIMITS_MAX_VOLUME, settings.KUBERNETES_LIMITS_MIN_VOLUME)
63+
VOLUME_PATH_MATCH = re.compile(r'^\/(\w+\/?)+$', re.IGNORECASE)
64+
METRIC_EVERY_MATCH = re.compile(r'^[1-9][0-9]*m$')
4365

4466

4567
class JSONFieldSerializer(serializers.JSONField):
@@ -179,9 +201,7 @@ def validate_values(data):
179201
continue
180202

181203
if not re.match(CONFIGKEY_MATCH, key):
182-
raise serializers.ValidationError(
183-
"Config keys must start with a letter or underscore and "
184-
"only contain [A-z0-9_]")
204+
raise serializers.ValidationError(CONFIGKEY_MISMATCH_MSG)
185205

186206
# Validate PORT
187207
if key == 'PORT':
@@ -231,9 +251,7 @@ def validate_memory(data):
231251
raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
232252

233253
if not re.match(MEMLIMIT_MATCH, str(value)):
234-
raise serializers.ValidationError(
235-
"Memory limit format: <number><unit>, "
236-
"where unit = M or G")
254+
raise serializers.ValidationError(MEMLIMIT_MISMATCH_MSG)
237255
range_error = "Memory setting is not in allowed range: %sM~%sM" % (
238256
min_memory, max_memory)
239257
memory_size = int(value[:-1]) * 1024 if value.endswith("G") else int(value[:-1])
@@ -255,8 +273,7 @@ def validate_cpu(data):
255273

256274
shares = re.match(CPUSHARE_MATCH, str(value))
257275
if not shares:
258-
raise serializers.ValidationError(
259-
"CPU limit format: <value>, where value must be a numeric")
276+
raise serializers.ValidationError(CPUSHARE_MISMATCH_MSG)
260277
range_error = "CPU setting is not in allowed range: %sm~%sm" % (
261278
min_cpu, max_cpu)
262279
cpu_size = int(value) * 1000 if value.isdigit() else int(value[:-1])
@@ -322,9 +339,7 @@ def validate_registry(data):
322339
continue
323340

324341
if not re.match(CONFIGKEY_MATCH, key):
325-
raise serializers.ValidationError(
326-
"Config keys must start with a letter or underscore and "
327-
"only contain [A-z0-9_]")
342+
raise serializers.ValidationError(CONFIGKEY_MISMATCH_MSG)
328343

329344
return data
330345

@@ -560,28 +575,27 @@ class VolumeSerializer(serializers.ModelSerializer):
560575
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
561576
owner = serializers.ReadOnlyField(source='owner.username')
562577
name = serializers.CharField()
563-
size = serializers.CharField()
578+
size = serializers.CharField(required=False)
564579
path = JSONFieldSerializer(required=False, binary=True)
580+
type = serializers.CharField(required=False)
581+
parameters = serializers.JSONField(required=False)
565582

566583
class Meta:
567584
"""Metadata options for a :class:`AppVolumeSerializer`."""
568585
model = models.volume.Volume
569586
fields = '__all__'
570587

571-
@staticmethod
572-
def validate_size(data):
588+
def validate_size(self, data):
589+
# check size format
573590
if not re.match(VOLUME_SIZE_MATCH, data):
574-
raise serializers.ValidationError(
575-
"Volume size limit format: <number><unit> or <number><unit>/<number><unit>, "
576-
"where unit = G")
591+
raise serializers.ValidationError(VOLUME_SIZE_MISMATCH_MSG)
592+
# check volume size
593+
volume_size = int(data[:-1])
577594
max_volume = settings.KUBERNETES_LIMITS_MAX_VOLUME
578595
# The minimum limit memory is equal to the memory allocated by default
579596
min_volume = settings.KUBERNETES_LIMITS_MIN_VOLUME
580-
range_error = "Volume setting is not in allowed range: %sG~%sG" % (
581-
min_volume, max_volume)
582-
volume_size = int(data[:-1])
583597
if volume_size < min_volume or volume_size > max_volume:
584-
raise serializers.ValidationError(range_error)
598+
raise serializers.ValidationError(VOLUME_SIZE_MISMATCH_MSG)
585599
return data.upper()
586600

587601
@staticmethod
@@ -595,7 +609,7 @@ def validate_path(data):
595609
new_data[key] = value
596610
continue
597611

598-
if not re.match(VOLUME_PATH, str(value)):
612+
if not re.match(VOLUME_PATH_MATCH, str(value)):
599613
raise serializers.ValidationError(
600614
"Volume path format: /path")
601615
if value.endswith("/"):
@@ -604,6 +618,23 @@ def validate_path(data):
604618
logger.debug(f"mount validate_path new_data: {new_data}")
605619
return new_data
606620

621+
def validate_type(self, data):
622+
if not re.match(VOLUME_TYPE_MATCH, data):
623+
raise serializers.ValidationError(VOLUME_TYPE_MISMATCH_MSG)
624+
elif data != "csi" and not self.initial_data.get("parameters", None):
625+
raise serializers.ValidationError(
626+
"parameters cannot be empty when the type is not csi.")
627+
return data
628+
629+
@staticmethod
630+
def validate_parameters(data):
631+
try:
632+
jsonschema.validate(data, VOLUMES_SCHEMA)
633+
except jsonschema.ValidationError as e:
634+
raise serializers.ValidationError(
635+
"could not validate {}: {}".format(data, e.message))
636+
return data
637+
607638

608639
class ResourceSerializer(serializers.ModelSerializer):
609640
"""Serialize a :class:`~api.models.resource.Resource` model."""
@@ -641,9 +672,9 @@ class MetricSerializer(serializers.Serializer):
641672
every = serializers.CharField(max_length=50, required=False, default='5m')
642673

643674
def validate(self, attrs):
644-
if not re.match(METRIC_EVERY, attrs["every"]):
675+
if not re.match(METRIC_EVERY_MATCH, attrs["every"]):
645676
raise serializers.ValidationError(
646-
"The format of every is:%s" % METRIC_EVERY.pattern
677+
"The format of every is:%s" % METRIC_EVERY_MATCH.pattern
647678
)
648679
interval = attrs.get("stop") - attrs.get("start")
649680
if interval < 0 or interval > 3600 * 24:

rootfs/api/tasks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def mount_app(app, user, volume):
101101
app.mount(user, volume)
102102
volume.save()
103103
except Exception as e:
104+
print(e)
104105
signals.got_request_exception.send(sender=task_id)
105106
raise e
106107
else:

0 commit comments

Comments
 (0)