Skip to content

Commit e259119

Browse files
authored
Merge pull request #71 from notmaxx/feature_services
feat(controller): extra services support
2 parents d63c006 + 5102b2e commit e259119

9 files changed

Lines changed: 414 additions & 9 deletions

File tree

rootfs/api/management/commands/load_db_state_to_k8s.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.core.management.base import BaseCommand
22
from django.shortcuts import get_object_or_404
33

4-
from api.models import Key, App, Domain, Certificate
4+
from api.models import Key, App, Domain, Certificate, Service
55
from api.exceptions import DeisException, AlreadyExists
66

77

@@ -52,7 +52,7 @@ def save_apps(self):
5252
except DeisException as error:
5353
print('ERROR: Problem saving to model {} for {}'
5454
'due to {}'.format(str(App.__name__), str(app), str(error)))
55-
for model in (Key, Domain, Certificate):
55+
for model in (Key, Domain, Certificate, Service):
5656
for obj in model.objects.all():
5757
try:
5858
obj.save()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.11.4 on 2018-04-24 17:42
3+
from __future__ import unicode_literals
4+
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
import django.db.models.deletion
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
('api', '0026_release_exception'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='Service',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('created', models.DateTimeField(auto_now_add=True)),
23+
('updated', models.DateTimeField(auto_now=True)),
24+
('procfile_type', models.TextField()),
25+
('path_pattern', models.TextField()),
26+
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.App')),
27+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
28+
],
29+
options={
30+
'ordering': ['-created'],
31+
'get_latest_by': 'created',
32+
},
33+
),
34+
migrations.AlterUniqueTogether(
35+
name='service',
36+
unique_together=set([('app', 'procfile_type')]),
37+
),
38+
]

rootfs/api/models/__init__.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,12 @@ def _scheduler(self):
7272
mod = importlib.import_module(settings.SCHEDULER_MODULE)
7373
return mod.SchedulerClient(settings.SCHEDULER_URL, settings.K8S_API_VERIFY_TLS)
7474

75-
def _fetch_service_config(self, app):
75+
def _fetch_service_config(self, app, svc_name=None):
7676
try:
7777
# Get the service from k8s to attach the domain correctly
78-
svc = self._scheduler.svc.get(app, app).json()
78+
if svc_name is None:
79+
svc_name = app
80+
svc = self._scheduler.svc.get(app, svc_name).json()
7981
except KubeException as e:
8082
raise ServiceUnavailable('Could not fetch Kubernetes Service {}'.format(app)) from e
8183

@@ -90,9 +92,9 @@ def _fetch_service_config(self, app):
9092

9193
return svc
9294

93-
def _load_service_config(self, app, component):
95+
def _load_service_config(self, app, component, svc_name=None):
9496
# fetch setvice definition with minimum structure
95-
svc = self._fetch_service_config(app)
97+
svc = self._fetch_service_config(app, svc_name)
9698

9799
# always assume a .deis.io/ ending
98100
component = "%s.deis.io/" % component
@@ -103,9 +105,11 @@ def _load_service_config(self, app, component):
103105

104106
return config
105107

106-
def _save_service_config(self, app, component, data):
108+
def _save_service_config(self, app, component, data, svc_name=None):
109+
if svc_name is None:
110+
svc_name = app
107111
# fetch setvice definition with minimum structure
108-
svc = self._fetch_service_config(app)
112+
svc = self._fetch_service_config(app, svc_name)
109113

110114
# always assume a .deis.io ending
111115
component = "%s.deis.io/" % component
@@ -116,7 +120,7 @@ def _save_service_config(self, app, component, data):
116120

117121
# Update the k8s service for the application with new service information
118122
try:
119-
self._scheduler.svc.update(app, app, svc)
123+
self._scheduler.svc.update(app, svc_name, svc)
120124
except KubeException as e:
121125
raise ServiceUnavailable('Could not update Kubernetes Service {}'.format(app)) from e
122126

@@ -142,6 +146,7 @@ class Meta:
142146
from .certificate import Certificate, validate_certificate # noqa
143147
from .config import Config # noqa
144148
from .domain import Domain # noqa
149+
from .service import Service # noqa
145150
from .key import Key, validate_base64 # noqa
146151
from .release import Release # noqa
147152
from .tls import TLS # noqa

rootfs/api/models/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,10 @@ def maintenance_mode(self, mode):
892892
self._scheduler.svc.update(self.id, self.id, data=old_service)
893893
raise ServiceUnavailable(str(e)) from e
894894

895+
# set maintenance mode for services
896+
for svc in self.service_set.all():
897+
svc.maintenance_mode(mode)
898+
895899
def routable(self, routable):
896900
"""
897901
Turn on/off if an application is publically routable

rootfs/api/models/service.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import logging
2+
3+
from django.db import models
4+
from django.conf import settings
5+
6+
from api.models import AuditedModel, ServiceUnavailable
7+
from scheduler import KubeException
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class Service(AuditedModel):
13+
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
14+
app = models.ForeignKey('App', on_delete=models.CASCADE)
15+
procfile_type = models.TextField(blank=False, null=False, unique=False)
16+
path_pattern = models.TextField(blank=False, null=False, unique=False)
17+
18+
class Meta:
19+
get_latest_by = 'created'
20+
unique_together = (('app', 'procfile_type'))
21+
ordering = ['-created']
22+
23+
def __str__(self):
24+
return self._svc_name()
25+
26+
def as_dict(self):
27+
return {
28+
"procfile_type": self.procfile_type,
29+
"path_pattern": self.path_pattern
30+
}
31+
32+
def create(self, *args, **kwargs): # noqa
33+
# create required minimum service in k8s for the application
34+
namespace = self._namespace()
35+
svc_name = self._svc_name()
36+
self.log('creating Service: {}'.format(svc_name), level=logging.DEBUG)
37+
try:
38+
try:
39+
self._scheduler.svc.get(namespace, svc_name)
40+
except KubeException:
41+
self._scheduler.svc.create(namespace, svc_name)
42+
except KubeException as e:
43+
raise ServiceUnavailable('Kubernetes service could not be created') from e
44+
# config service
45+
annotations = self._gather_settings()
46+
routable = annotations.pop('routable')
47+
self._update_service(namespace, self.procfile_type, routable, annotations)
48+
49+
def save(self, *args, **kwargs):
50+
service = super(Service, self).save(*args, **kwargs)
51+
52+
self.create()
53+
54+
return service
55+
56+
def delete(self, *args, **kwargs):
57+
namespace = self._namespace()
58+
svc_name = self._svc_name()
59+
self.log('deleting Service: {}'.format(svc_name), level=logging.DEBUG)
60+
try:
61+
self._scheduler.svc.delete(namespace, svc_name)
62+
except KubeException:
63+
# swallow exception
64+
# raise ServiceUnavailable('Kubernetes service could not be deleted') from e
65+
self.log('Kubernetes service cannot be deleted: {}'.format(svc_name),
66+
level=logging.ERROR)
67+
68+
# Delete from DB
69+
return super(Service, self).delete(*args, **kwargs)
70+
71+
def log(self, message, level=logging.INFO):
72+
"""Logs a message in the context of this service.
73+
74+
This prefixes log messages with an application "tag" that the customized deis-logspout will
75+
be on the lookout for. When it's seen, the message-- usually an application event of some
76+
sort like releasing or scaling, will be considered as "belonging" to the application
77+
instead of the controller and will be handled accordingly.
78+
"""
79+
logger.log(level, "[{}]: {}".format(self.id, message))
80+
81+
def maintenance_mode(self, mode):
82+
"""
83+
Turn service maintenance mode on/off
84+
"""
85+
namespace = self._namespace()
86+
svc_name = self._svc_name()
87+
88+
try:
89+
service = self._fetch_service_config(namespace, svc_name)
90+
except (ServiceUnavailable, KubeException) as e:
91+
# ignore non-existing services
92+
return
93+
94+
old_service = service.copy() # in case anything fails for rollback
95+
96+
try:
97+
service['metadata']['annotations']['router.deis.io/maintenance'] = str(mode).lower()
98+
self._scheduler.svc.update(namespace, svc_name, data=service)
99+
except KubeException as e:
100+
self._scheduler.svc.update(namespace, svc_name, data=old_service)
101+
raise ServiceUnavailable(str(e)) from e
102+
103+
def _namespace(self):
104+
return self.app.id
105+
106+
def _svc_name(self):
107+
return "{}-{}".format(self.app.id, self.procfile_type)
108+
109+
def _gather_settings(self):
110+
app_settings = self.app.appsettings_set.latest()
111+
return {
112+
'domains': self._svc_name(),
113+
'maintenance': app_settings.maintenance,
114+
'routable': app_settings.routable,
115+
'proxyDomain': self.app.id,
116+
'proxyLocations': self.path_pattern
117+
}
118+
119+
def _update_service(self, namespace, app_type, routable, annotations): # noqa
120+
"""Update application service with all the various required information"""
121+
svc_name = "{}-{}".format(namespace, app_type)
122+
service = self._fetch_service_config(namespace, svc_name)
123+
old_service = service.copy() # in case anything fails for rollback
124+
125+
try:
126+
# Update service information
127+
for key, value in annotations.items():
128+
if value is not None:
129+
service['metadata']['annotations']['router.deis.io/%s' % key] = str(value)
130+
else:
131+
service['metadata']['annotations'].pop('router.deis.io/%s' % key, None)
132+
if routable:
133+
service['metadata']['labels']['router.deis.io/routable'] = 'true'
134+
else:
135+
# delete the annotation
136+
service['metadata']['labels'].pop('router.deis.io/routable', None)
137+
138+
# Set app type selector
139+
service['spec']['selector']['type'] = app_type
140+
141+
self._scheduler.svc.update(namespace, svc_name, data=service)
142+
except Exception as e:
143+
# Fix service to old port and app type
144+
self._scheduler.svc.update(namespace, svc_name, data=old_service)
145+
raise ServiceUnavailable(str(e)) from e

rootfs/api/serializers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,40 @@ def ToACE(x): return idna.alabel(x).decode("utf-8", "strict")
472472
return aceValue
473473

474474

475+
class ServiceSerializer(serializers.ModelSerializer):
476+
"""Serialize a :class:`~api.models.Service` model."""
477+
478+
app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all())
479+
owner = serializers.ReadOnlyField(source='owner.username')
480+
procfile_type = serializers.CharField(allow_blank=False, allow_null=False, required=True)
481+
path_pattern = serializers.CharField(allow_blank=False, allow_null=False, required=True)
482+
483+
class Meta:
484+
"""Metadata options for a :class:`ServiceSerializer`."""
485+
model = models.Service
486+
fields = ['owner', 'created', 'updated', 'app', 'procfile_type', 'path_pattern']
487+
read_only_fields = ['uuid']
488+
489+
def validate_procfile_type(self, value):
490+
if not re.match(PROCTYPE_MATCH, value):
491+
raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
492+
493+
return value
494+
495+
def validate_path_pattern(self, value):
496+
for pattern in str(value).split(","):
497+
if not pattern.strip():
498+
raise serializers.ValidationError(
499+
"Service value should be valid regex (or set of regex split by comma)")
500+
try:
501+
re.compile(pattern)
502+
except re.error as e:
503+
raise serializers.ValidationError(
504+
"Service value should be valid regex (or set of regex split by comma)")
505+
506+
return value
507+
508+
475509
class CertificateSerializer(serializers.ModelSerializer):
476510
"""Serialize a :class:`~api.models.Cert` model."""
477511

0 commit comments

Comments
 (0)