Skip to content

Commit 5037bbf

Browse files
committed
feat(controller): improve the services api
1 parent cb0eae1 commit 5037bbf

8 files changed

Lines changed: 133 additions & 27 deletions

File tree

rootfs/api/migrations/0001_initial.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 3.2.14 on 2022-08-10 02:21
1+
# Generated by Django 4.1.7 on 2023-03-17 08:18
22

33
import api.models.app
44
import api.models.certificate
@@ -36,8 +36,8 @@ class Migration(migrations.Migration):
3636
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
3737
('id', models.BigIntegerField(primary_key=True, serialize=False, verbose_name='id')),
3838
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
39-
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
40-
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
39+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
40+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
4141
],
4242
options={
4343
'verbose_name': 'user',
@@ -202,6 +202,9 @@ class Migration(migrations.Migration):
202202
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
203203
('created', models.DateTimeField(auto_now_add=True)),
204204
('updated', models.DateTimeField(auto_now=True)),
205+
('port', models.PositiveIntegerField(default=5000)),
206+
('protocol', models.TextField(default='TCP')),
207+
('target_port', models.PositiveIntegerField(default=5000)),
205208
('service_type', models.TextField()),
206209
('procfile_type', models.TextField()),
207210
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.app')),

rootfs/api/models/service.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from django.db import models
4+
from django.conf import settings
45
from django.contrib.auth import get_user_model
56
from api.exceptions import ServiceUnavailable
67
from scheduler import KubeException
@@ -13,8 +14,11 @@
1314
class Service(AuditedModel):
1415
owner = models.ForeignKey(User, on_delete=models.PROTECT)
1516
app = models.ForeignKey('App', on_delete=models.CASCADE)
16-
service_type = models.TextField(blank=False, null=False, unique=False)
17-
procfile_type = models.TextField(blank=False, null=False, unique=False)
17+
port = models.PositiveIntegerField(default=5000)
18+
protocol = models.TextField(default="TCP")
19+
target_port = models.PositiveIntegerField(default=5000)
20+
service_type = models.TextField()
21+
procfile_type = models.TextField()
1822

1923
class Meta:
2024
get_latest_by = 'created'
@@ -24,22 +28,50 @@ class Meta:
2428
def __str__(self):
2529
return self._svc_name()
2630

31+
def _get_ips(self):
32+
namespace = self._namespace()
33+
svc_name = self._svc_name()
34+
response = self._scheduler.svc.get(namespace, svc_name)
35+
data = response.json()
36+
cluster_ip = data['spec']['clusterIP']
37+
if 'ingress' in data['status']['loadBalancer']:
38+
external_ip = data['status']['loadBalancer']['ingress'][0]['ip']
39+
else:
40+
external_ip = None
41+
return cluster_ip, external_ip
42+
2743
def as_dict(self):
44+
namespace = self._namespace()
45+
svc_name = self._svc_name()
46+
cluster_domain = settings.KUBERNETES_CLUSTER_DOMAIN
47+
cluster_ip, external_ip = self._get_ips()
2848
return {
49+
"port": self.port,
50+
"domain": f"{svc_name}.{namespace}.svc.{cluster_domain}",
51+
"protocol": self.protocol,
52+
"cluster_ip": cluster_ip,
53+
"external_ip": external_ip,
54+
"target_port": self.target_port,
2955
"service_type": self.service_type,
30-
"procfile_type": self.procfile_type
56+
"procfile_type": self.procfile_type,
3157
}
3258

33-
def create(self, *args, **kwargs): # noqa
34-
# create required minimum service in k8s for the application
59+
def create(self):
3560
namespace = self._namespace()
3661
svc_name = self._svc_name()
37-
self.log('creating Service: {}'.format(svc_name), level=logging.DEBUG)
62+
self.log('creating service: {}'.format(svc_name), level=logging.DEBUG)
3863
try:
3964
try:
4065
self._scheduler.svc.get(namespace, svc_name)
4166
except KubeException:
42-
self._scheduler.svc.create(namespace, svc_name, self.service_type)
67+
self._scheduler.svc.create(
68+
namespace,
69+
svc_name,
70+
service_type=self.service_type,
71+
port=self.port,
72+
protocol=self.protocol,
73+
target_port=self.target_port,
74+
)
4375
except KubeException as e:
4476
raise ServiceUnavailable('Kubernetes service could not be created') from e
4577

rootfs/api/serializers.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525

2626
User = get_user_model()
2727
logger = logging.getLogger(__name__)
28-
2928
SVCTYPE_MATCH = re.compile(r'^(ClusterIP|LoadBalancer)$')
3029
SVCTYPE_MISMATCH_MSG = "The service type currently only supports ClusterIP and LoadBalancer"
30+
PROTOCOL_MATCH = re.compile(r'^(TCP|UDP|SCTP)$')
31+
PROTOCOL_MISMATCH_MSG = "Currently, the protocol only supports TCP, UDP, and SCTP"
3132
PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)$')
3233
PROCTYPE_MISMATCH_MSG = "Process types can only contain lowercase alphanumeric characters"
3334
MEMLIMIT_MATCH = re.compile(r'^(?P<mem>([1-9][0-9]*[mgMG]))$', re.IGNORECASE)
@@ -513,15 +514,40 @@ class ServiceSerializer(serializers.ModelSerializer):
513514

514515
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
515516
owner = serializers.ReadOnlyField(source='owner.username')
516-
service_type = serializers.CharField(allow_blank=False, allow_null=False, required=True)
517-
procfile_type = serializers.CharField(allow_blank=False, allow_null=False, required=True)
517+
port = serializers.IntegerField(default=5000)
518+
protocol = serializers.CharField(default="TCP")
519+
target_port = serializers.IntegerField(default=5000)
520+
service_type = serializers.CharField(required=True)
521+
procfile_type = serializers.CharField(required=True)
518522

519523
class Meta:
520524
"""Metadata options for a :class:`ServiceSerializer`."""
521525
model = models.service.Service
522526
fields = ['owner', 'created', 'updated', 'app', 'service_type', 'procfile_type']
523527
read_only_fields = ['uuid']
524528

529+
@staticmethod
530+
def validate_port(value):
531+
if not str(value).isnumeric():
532+
raise serializers.ValidationError('port can only be a numeric value')
533+
elif int(value) not in range(1, 65536):
534+
raise serializers.ValidationError('port needs to be between 1 and 65535')
535+
return value
536+
537+
@staticmethod
538+
def validate_protocol(value):
539+
if not re.match(PROTOCOL_MATCH, value):
540+
raise serializers.ValidationError(PROTOCOL_MISMATCH_MSG)
541+
return value
542+
543+
@staticmethod
544+
def validate_target_port(value):
545+
if not str(value).isnumeric():
546+
raise serializers.ValidationError('target port can only be a numeric value')
547+
elif int(value) not in range(1, 65536):
548+
raise serializers.ValidationError('target port needs to be between 1 and 65535')
549+
return value
550+
525551
@staticmethod
526552
def validate_service_type(value):
527553
if not re.match(SVCTYPE_MATCH, value):

rootfs/api/settings/production.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,16 +253,16 @@
253253
# the k8s namespace in which the controller and workflow were installed.
254254
WORKFLOW_NAMESPACE = os.environ.get('WORKFLOW_NAMESPACE', 'drycc')
255255

256+
# kubernetes cluster domain
257+
KUBERNETES_CLUSTER_DOMAIN = os.environ.get("KUBERNETES_CLUSTER_DOMAIN", "cluster.local")
256258
# default scheduler settings
257259
SCHEDULER_MODULE = 'scheduler'
258260
SCHEDULER_URL = "https://{}:{}".format(
259261
# accessing the k8s api server by IP address rather than hostname avoids
260262
# intermittent DNS errors
261263
os.environ.get(
262264
'KUBERNETES_SERVICE_HOST',
263-
'kubernetes.default.svc.{}'.format(os.environ.get(
264-
"KUBERNETES_CLUSTER_DOMAIN", "cluster.local"
265-
))
265+
'kubernetes.default.svc.{}'.format(KUBERNETES_CLUSTER_DOMAIN)
266266
),
267267
os.environ.get('KUBERNETES_SERVICE_PORT', '443')
268268
)

rootfs/api/tests/test_services.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,44 +34,71 @@ def test_service_basic_ops(self):
3434
# create 1st service
3535
response = self.client.post(
3636
'/v2/apps/{}/services'.format(app_id),
37-
{'service_type': 'ClusterIP', 'procfile_type': 'test'}
37+
{
38+
'port': 5000,
39+
'protocol': 'UDP',
40+
'target_port': 5000,
41+
'service_type': 'ClusterIP',
42+
'procfile_type': 'test'
43+
}
3844
)
3945
self.assertEqual(response.status_code, 201, response.data)
4046
# list 1st service
4147
response = self.client.get('/v2/apps/{}/services'.format(app_id))
4248
self.assertEqual(response.status_code, 200, response.data)
4349
self.assertEqual(len(response.data['services']), 1)
4450
expected1 = {
51+
'port': 5000,
52+
'protocol': 'UDP',
53+
'target_port': 5000,
4554
'service_type': 'ClusterIP',
4655
'procfile_type': 'test'
4756
}
4857
self.assertDictContainsSubset(expected1, response.data['services'][0])
4958
# update 1st service
5059
response = self.client.post(
5160
'/v2/apps/{}/services'.format(app_id),
52-
{'service_type': 'LoadBalancer', 'procfile_type': 'test'}
61+
{
62+
'port': 5000,
63+
'protocol': 'UDP',
64+
'target_port': 5000,
65+
'service_type': 'LoadBalancer',
66+
'procfile_type': 'test'
67+
}
5368
)
5469
self.assertEqual(response.status_code, 201, response.data)
5570
# list 1st service and get new value
5671
response = self.client.get('/v2/apps/{}/services'.format(app_id))
5772
self.assertEqual(response.status_code, 200, response.data)
5873
self.assertEqual(len(response.data['services']), 1)
5974
expected1 = {
75+
'port': 5000,
76+
'protocol': 'UDP',
77+
'target_port': 5000,
6078
'service_type': 'LoadBalancer',
6179
'procfile_type': 'test'
6280
}
6381
self.assertDictContainsSubset(expected1, response.data['services'][0])
6482
# create 2nd service
6583
response = self.client.post(
6684
'/v2/apps/{}/services'.format(app_id),
67-
{'service_type': 'ClusterIP', 'procfile_type': 'test2'}
85+
{
86+
'port': 5000,
87+
'protocol': 'UDP',
88+
'target_port': 5000,
89+
'service_type': 'ClusterIP',
90+
'procfile_type': 'test2'
91+
}
6892
)
6993
self.assertEqual(response.status_code, 201, response.data)
7094
# list two services
7195
response = self.client.get('/v2/apps/{}/services'.format(app_id))
7296
self.assertEqual(response.status_code, 200, response.data)
7397
self.assertEqual(len(response.data['services']), 2)
7498
expected2 = {
99+
'port': 5000,
100+
'protocol': 'UDP',
101+
'target_port': 5000,
75102
'service_type': 'ClusterIP',
76103
'procfile_type': 'test2'
77104
}

rootfs/api/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,9 @@ def list(self, *args, **kwargs):
423423
def create_or_update(self, request, **kwargs):
424424
svt = self.get_serializer().validate_service_type(request.data.get('service_type'))
425425
pft = self.get_serializer().validate_procfile_type(request.data.get('procfile_type'))
426+
port = self.get_serializer().validate_port(request.data.get('port'))
427+
protocol = self.get_serializer().validate_protocol(request.data.get('protocol'))
428+
target_port = self.get_serializer().validate_target_port(request.data.get('target_port'))
426429
app = self.get_app()
427430
svc = app.service_set.filter(procfile_type=pft).first()
428431
if svc:
@@ -433,7 +436,14 @@ def create_or_update(self, request, **kwargs):
433436
svc.save()
434437
else:
435438
svc = models.service.Service.objects.create(
436-
owner=app.owner, app=app, service_type=svt, procfile_type=pft)
439+
owner=app.owner,
440+
app=app,
441+
service_type=svt,
442+
procfile_type=pft,
443+
port=port,
444+
protocol=protocol,
445+
target_port=target_port,
446+
)
437447
return Response(status=status.HTTP_201_CREATED)
438448

439449
def delete(self, request, **kwargs):

rootfs/scheduler/mock.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,15 @@ def post(request, context):
702702
namespace = re.sub(r'/apis/.+/.+/namespaces/', '', namespace)
703703
namespace = namespace.split('/')[0]
704704
data['metadata']['namespace'] = namespace
705-
705+
if resource_type in ["services"]:
706+
data['spec']["clusterIP"] = "10.1.9.101"
707+
data['status'] = {
708+
'loadBalancer': {
709+
'ingress': [
710+
{'ip': "47.98.100.101"}
711+
]
712+
}
713+
}
706714
# Handle RC / RS / Deployments
707715
if resource_type in ['replicationcontrollers', 'replicasets', 'deployments']:
708716
data['status'] = {

rootfs/scheduler/resources/service.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def get(self, namespace, name=None, **kwargs):
2727

2828
return response
2929

30-
def create(self, namespace, name, type="ClusterIP", **kwargs):
30+
def create(self, namespace, name, **kwargs):
3131
# Ports and app type will be overwritten as required
3232
manifest = {
3333
'kind': 'Service',
@@ -41,12 +41,12 @@ def create(self, namespace, name, type="ClusterIP", **kwargs):
4141
'annotations': {}
4242
},
4343
'spec': {
44-
'type': type,
44+
'type': kwargs.get("type", "ClusterIP"),
4545
'ports': [{
46-
'name': 'http',
47-
'port': 80,
48-
'targetPort': 5000,
49-
'protocol': 'TCP'
46+
'name': name,
47+
'port': kwargs.get("port", 80),
48+
'targetPort': kwargs.get("target_port", 5000),
49+
'protocol': kwargs.get("protocol", "TCP"),
5050
}],
5151
'selector': {
5252
'app': name,

0 commit comments

Comments
 (0)