Skip to content

Commit af39197

Browse files
author
Your Name
committed
feat(controller): add a volume command
1 parent b1cff04 commit af39197

18 files changed

Lines changed: 625 additions & 12 deletions

File tree

charts/controller/templates/controller-clusterrole.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,8 @@ rules:
6262
- apiGroups: ["apps"]
6363
resources: ["replicasets"]
6464
verbs: ["get", "list", "watch"]
65+
- apiGroups: [""]
66+
resources: ["persistentvolumeclaims"]
67+
verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
6568
{{- end -}}
6669
{{- end -}}

charts/controller/templates/controller-deployment.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ spec:
7575
value: "{{ .Values.global.registry_secret_prefix }}"
7676
- name: "IMAGE_PULL_POLICY"
7777
value: "{{ .Values.app_pull_policy }}"
78+
{{- if (.Values.app_storage_class) }}
79+
- name: "DRYCC_APP_KUBERNETES_STORAGE_CLASS"
80+
value: "{{ .Values.app_storage_class }}"
81+
{{- end }}
7882
- name: "TZ"
7983
value: {{ .Values.time_zone | default "UTC" | quote }}
8084
{{- if (.Values.deploy_hook_urls) }}

charts/controller/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ registration_mode: "admin_only"
1616
# Option to disable ssl verification to connect to k8s api server
1717
k8s_api_verify_tls: "true"
1818

19+
# Set storageClassName, It is used for application mount.
20+
app_storage_class: ""
21+
1922
global:
2023
# Admin email, used for each component to send email to administrator
2124
email: "drycc@drycc.cc"

rootfs/api/migrations/0001_initial.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,22 @@ class Migration(migrations.Migration):
233233
name='appsettings',
234234
unique_together=set([('app', 'uuid')]),
235235
),
236+
migrations.CreateModel(
237+
name='Volume',
238+
fields=[
239+
('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')),
240+
('created', models.DateTimeField(auto_now_add=True)),
241+
('updated', models.DateTimeField(auto_now=True)),
242+
('name', models.CharField(max_length=63, unique=True, validators=[api.models.validate_label])),
243+
('size', models.CharField(max_length=128)),
244+
('path', jsonfield.fields.JSONField(blank=True, default={})),
245+
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.App')),
246+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
247+
],
248+
options={
249+
'ordering': ['-created'],
250+
'get_latest_by': 'created',
251+
'unique_together': {('app', 'name')},
252+
},
253+
),
236254
]

rootfs/api/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class Meta:
150150
from .key import Key, validate_base64 # noqa
151151
from .release import Release # noqa
152152
from .tls import TLS # noqa
153+
from .volume import Volume # noqa
153154

154155
# define update/delete callbacks for synchronizing
155156
# models with the configuration management backend
@@ -246,6 +247,7 @@ def _hook_release_created(**kwargs):
246247
post_delete.connect(_log_instance_removed, sender=Certificate, dispatch_uid='api.models.log')
247248
post_delete.connect(_log_instance_removed, sender=Domain, dispatch_uid='api.models.log')
248249
post_delete.connect(_log_instance_removed, sender=TLS, dispatch_uid='api.models.log')
250+
post_delete.connect(_log_instance_removed, sender=Volume, dispatch_uid='api.models.log')
249251

250252

251253
# automatically generate a new token on creation

rootfs/api/models/app.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import requests
1212
import string
1313
import time
14+
import uuid
1415
from urllib.parse import urljoin
1516

1617
from django.conf import settings
@@ -25,6 +26,7 @@
2526
from api.models.release import Release
2627
from api.models.tls import TLS
2728
from api.models.appsettings import AppSettings
29+
from api.models.volume import Volume
2830
from api.utils import generate_app_name, async_run
2931
from scheduler import KubeHTTPException, KubeException
3032

@@ -497,7 +499,7 @@ def scale(self, user, structure): # noqa
497499
def _scale_pods(self, scale_types):
498500
release = self.release_set.filter(failed=False).latest()
499501
app_settings = self.appsettings_set.latest()
500-
502+
volumes = Volume.objects.filter(app=self, path__isnull=False)
501503
# use slugrunner image for app if buildpack app otherwise use normal image
502504
if release.build.type == 'buildpack':
503505
image = next(filter(lambda item: item['name'] == release.build.stack,
@@ -507,7 +509,8 @@ def _scale_pods(self, scale_types):
507509

508510
tasks = []
509511
for scale_type, replicas in scale_types.items():
510-
data = self._gather_app_settings(release, app_settings, scale_type, replicas) # noqa
512+
scale_type_volumes = [_ for _ in volumes if scale_type in _.path.keys()]
513+
data = self._gather_app_settings(release, app_settings, scale_type, replicas, volumes=scale_type_volumes) # noqa
511514

512515
# gather all proc types to be deployed
513516
tasks.append(
@@ -577,9 +580,11 @@ def deploy(self, release, force_deploy=False, rollback_on_failure=True): # noqa
577580

578581
# deploy application to k8s. Also handles initial scaling
579582
app_settings = self.appsettings_set.latest()
583+
volumes = self.volume_set.all()
580584
deploys = {}
581585
for scale_type, replicas in self.structure.items():
582-
deploys[scale_type] = self._gather_app_settings(release, app_settings, scale_type, replicas) # noqa
586+
volumes = [_ for _ in volumes if scale_type in _.path.keys()]
587+
deploys[scale_type] = self._gather_app_settings(release, app_settings, scale_type, replicas, volumes=volumes) # noqa
583588

584589
# Sort deploys so routable comes first
585590
deploys = OrderedDict(sorted(deploys.items(), key=lambda d: d[1].get('routable')))
@@ -798,7 +803,7 @@ def logs(self, log_lines=str(settings.LOG_LINES)):
798803
# cast content to string since it comes as bytes via the requests object
799804
return str(r.content.decode('utf-8'))
800805

801-
def run(self, user, command):
806+
def run(self, user, command, volumes=None):
802807
def pod_name(size=5, chars=string.ascii_lowercase + string.digits):
803808
return ''.join(random.choice(chars) for _ in range(size))
804809

@@ -814,8 +819,13 @@ def pod_name(size=5, chars=string.ascii_lowercase + string.digits):
814819
settings.SLUGRUNNER_IMAGES))['image']
815820
else:
816821
image = release.image
817-
818-
data = self._gather_app_settings(release, app_settings, process_type='run', replicas=1)
822+
volume_list = []
823+
if volumes:
824+
volume_objs = Volume.objects.filter(app=release.app, name__in=volumes.keys())
825+
for _ in volume_objs:
826+
_.path["{}-{}".format(self.app.id, str(uuid.uuid4())[:7])] = volumes.get(_.name, None) # noqa
827+
volume_list.append(_)
828+
data = self._gather_app_settings(release, app_settings, process_type='run', replicas=1, volumes=volume_list) # noqa
819829

820830
# create application config and build the pod manifest
821831
self.set_application_config(release)
@@ -1076,7 +1086,7 @@ def _get_private_registry_config(self, image, registry=None):
10761086
})
10771087
return docker_config, name, True
10781088

1079-
def _gather_app_settings(self, release, app_settings, process_type, replicas):
1089+
def _gather_app_settings(self, release, app_settings, process_type, replicas, volumes=None):
10801090
"""
10811091
Gathers all required information needed in one easy place for passing into
10821092
the Kubernetes client to deploy an application
@@ -1116,6 +1126,15 @@ def _gather_app_settings(self, release, app_settings, process_type, replicas):
11161126
healthcheck = config.get_healthcheck().get(process_type, {})
11171127
if not healthcheck and process_type in ['web', 'cmd']:
11181128
healthcheck = config.get_healthcheck().get('web/cmd', {})
1129+
volumes_info = [{
1130+
"name": _.name,
1131+
"claimName": _.name,
1132+
} for _ in volumes] if volumes else []
1133+
1134+
volume_mounts_info = [{
1135+
"name": _.name,
1136+
"mount_path": _.path.get(process_type),
1137+
} for _ in volumes] if volumes else []
11191138

11201139
return {
11211140
'memory': config.memory,
@@ -1140,6 +1159,8 @@ def _gather_app_settings(self, release, app_settings, process_type, replicas):
11401159
'pod_termination_grace_period_each': config.termination_grace_period,
11411160
'image_pull_secret_name': image_pull_secret_name,
11421161
'image_pull_policy': image_pull_policy,
1162+
'volumes': volumes_info,
1163+
'volume_mounts': volume_mounts_info,
11431164
}
11441165

11451166
def set_application_config(self, release):

rootfs/api/models/volume.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import logging
2+
from django.db import models, transaction
3+
from django.conf import settings
4+
from jsonfield import JSONField
5+
from api.exceptions import DryccException, ServiceUnavailable, AlreadyExists
6+
from api.models import UuidAuditedModel, validate_label
7+
from scheduler import KubeException
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class Volume(UuidAuditedModel):
13+
owner = models.ForeignKey(settings.AUTH_USER_MODEL,
14+
on_delete=models.PROTECT)
15+
app = models.ForeignKey('App', on_delete=models.CASCADE)
16+
17+
name = models.CharField(max_length=63, unique=True,
18+
validators=[validate_label])
19+
size = models.CharField(max_length=128, blank=False, null=False,
20+
unique=False)
21+
path = JSONField(default={}, blank=True)
22+
23+
class Meta:
24+
get_latest_by = 'created'
25+
unique_together = (('app', 'name'),)
26+
ordering = ['-created']
27+
28+
def __str__(self):
29+
return self.name
30+
31+
@transaction.atomic
32+
def save(self, *args, **kwargs):
33+
# Attach volume, updates k8s
34+
if self.created == self.updated:
35+
self.attach(*args, **kwargs)
36+
# Save to DB
37+
return super(Volume, self).save(*args, **kwargs)
38+
39+
@transaction.atomic
40+
def delete(self, *args, **kwargs):
41+
if self.path:
42+
raise DryccException("the volume is not unmounted")
43+
# Deatch volume, updates k8s
44+
self.detach(*args, **kwargs)
45+
# Delete from DB
46+
return super(Volume, self).delete(*args, **kwargs)
47+
48+
def attach(self, *args, **kwargs):
49+
try:
50+
self._scheduler.pvc.get(self.app.id, self.name)
51+
err = "Volume {} already exists in this namespace".format(self.name) # noqa
52+
self.log(err, logging.INFO)
53+
raise AlreadyExists(err)
54+
except KubeException as e:
55+
logger.info(e)
56+
try:
57+
kwargs = {
58+
"size": self.size
59+
}
60+
self._scheduler.pvc.create(self.app.id, self.name, **kwargs)
61+
except KubeException as e:
62+
msg = 'There was a problem creating the volume ' \
63+
'{} for {}'.format(self.name, self.app_id)
64+
raise ServiceUnavailable(msg) from e
65+
66+
def detach(self, *args, **kwargs):
67+
try:
68+
# We raise an exception when a volume doesn't exist
69+
self._scheduler.pvc.get(self.app.id, self.name)
70+
self._scheduler.pvc.delete(self.app.id, self.name)
71+
except KubeException as e:
72+
raise ServiceUnavailable("Could not delete volume {} for application \
73+
{}".format(name, self.app_id)) from e # noqa

rootfs/api/serializers.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
TAGVAL_MATCH = re.compile(r'^(?:[a-zA-Z\d][-\.\w]{0,61})?[a-zA-Z\d]$')
2929
CONFIGKEY_MATCH = re.compile(r'^[a-z_]+[a-z0-9_]*$', re.IGNORECASE)
3030
TERMINATION_GRACE_PERIOD_MATCH = re.compile(r'^[0-9]*$')
31+
VOLUME_SIZE_MATCH = re.compile(
32+
r'^(?P<mem>(([0-9]+(MB|KB|GB|[BKMG])|0)(/([0-9]+(MB|KB|GB|[BKMG])))?))$', re.IGNORECASE)
33+
VOLUME_PATH = re.compile(r'^\/(\w+\/?)+$', re.IGNORECASE)
34+
3135
PROBE_SCHEMA = {
3236
"$schema": "http://json-schema.org/schema#",
3337

@@ -607,6 +611,41 @@ class TLSSerializer(serializers.ModelSerializer):
607611
owner = serializers.ReadOnlyField(source='owner.username')
608612

609613
class Meta:
610-
"""Metadata options for a :class:`AppSettingsSerializer`."""
614+
"""Metadata options for a :class:`AppTLSSerializer`."""
611615
model = models.TLS
612616
fields = '__all__'
617+
618+
619+
class VolumeSerializer(serializers.ModelSerializer):
620+
"""Serialize a :class:`~api.models.Volume` model."""
621+
622+
app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all())
623+
owner = serializers.ReadOnlyField(source='owner.username')
624+
name = serializers.CharField()
625+
size = serializers.CharField()
626+
path = JSONFieldSerializer(required=False, binary=True)
627+
628+
class Meta:
629+
"""Metadata options for a :class:`AppVolumeSerializer`."""
630+
model = models.Volume
631+
fields = '__all__'
632+
633+
def validate_size(self, data):
634+
if not re.match(VOLUME_SIZE_MATCH, str(data)):
635+
raise serializers.ValidationError(
636+
"Volume size limit format: <number><unit> or <number><unit>/<number><unit>, "
637+
"where unit = B, K, M or G")
638+
return data
639+
640+
def validate_path(self, data):
641+
for key, value in data.items():
642+
if value is None: # use NoneType to unset an item
643+
continue
644+
645+
if not re.match(PROCTYPE_MATCH, key):
646+
raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
647+
648+
if not re.match(VOLUME_PATH, str(value)):
649+
raise serializers.ValidationError(
650+
"Volume path format: /path")
651+
return data

rootfs/api/settings/production.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@
326326

327327
DRYCC_DEPLOY_HOOK_SECRET_KEY = os.environ.get('DRYCC_DEPLOY_HOOK_SECRET_KEY', None)
328328

329+
DRYCC_APP_KUBERNETES_STORAGE_CLASS = os.environ.get('DRYCC_APP_KUBERNETES_STORAGE_CLASS', "") # noqa
330+
329331
KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT = os.environ.get('KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT', None) # noqa
330332

331333
DRYCC_DEFAULT_CONFIG_TAGS = os.environ.get('DRYCC_DEFAULT_CONFIG_TAGS', '')

rootfs/api/settings/testing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242

4343
DRYCC_DEFAULT_CONFIG_TAGS = os.environ.get('DRYCC_DEFAULT_CONFIG_TAGS', '')
4444

45+
DRYCC_APP_KUBERNETES_STORAGE_CLASS = os.environ.get('DRYCC_APP_KUBERNETES_STORAGE_CLASS', '') # noqa
46+
4547

4648
class DisableMigrations(object):
4749

0 commit comments

Comments
 (0)