Skip to content

Commit 3f84a2c

Browse files
committed
feat(config): add typed_values
1 parent 777d46d commit 3f84a2c

7 files changed

Lines changed: 118 additions & 62 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.10 on 2024-04-26 04:55
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0004_build_dryccfile_release_state_alter_service_ports'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='config',
15+
name='typed_values',
16+
field=models.JSONField(blank=True, default=dict),
17+
),
18+
]

rootfs/api/models/app.py

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -390,12 +390,12 @@ def pod_name(size=5, chars=string.ascii_lowercase + string.digits):
390390
data['restart_policy'] = 'Never'
391391
data['active_deadline_seconds'] = timeout
392392
data['ttl_seconds_after_finished'] = expires
393-
# create application config and build the pod manifest
394-
self.set_application_config(release)
395393
name = self._get_job_id(PROCFILE_TYPE_RUN, release.canary) + '-' + pod_name()
396394
self.log("{} on {} runs '{}'".format(user.username, name, command))
397395
kwargs.update(data)
398396
try:
397+
# create application config and build the pod manifest
398+
self.set_application_config(release, PROCFILE_TYPE_RUN)
399399
self.scheduler().job.create(
400400
self.id,
401401
name,
@@ -506,7 +506,7 @@ def image_pull_secret(self, namespace, registry, image):
506506

507507
return name
508508

509-
def set_application_config(self, release):
509+
def set_application_config(self, release, procfile_type):
510510
"""
511511
Creates the application config as a secret in Kubernetes and
512512
updates it if it already exists
@@ -520,13 +520,13 @@ def set_application_config(self, release):
520520

521521
# secrets use dns labels for keys, map those properly here
522522
secrets_env = {}
523-
for key, value in self._build_env_vars(release).items():
523+
for key, value in self._build_env_vars(release, procfile_type).items():
524524
secrets_env[key.lower().replace('_', '-')] = str(value)
525525

526526
# dictionary sorted by key
527527
secrets_env = OrderedDict(sorted(secrets_env.items(), key=lambda t: t[0]))
528528

529-
secret_name = "{}-{}-env".format(self.id, version)
529+
secret_name = "{}-{}-{}-env".format(self.id, procfile_type, version)
530530
try:
531531
self.scheduler().secret.get(self.id, secret_name)
532532
except KubeHTTPException:
@@ -588,6 +588,7 @@ def _mount(self, user, volume, release, app_settings, structure=None):
588588
self.id, self._get_job_id(scale_type, release.canary)).json()
589589
spec_annotations = deployment['spec']['template']['metadata'].get(
590590
'annotations', {})
591+
self.set_application_config(release, scale_type)
591592
# gather volume proc types to be deployed
592593
tasks.append(
593594
functools.partial(
@@ -603,8 +604,6 @@ def _mount(self, user, volume, release, app_settings, structure=None):
603604
)
604605
)
605606
try:
606-
# create the application config in k8s (secret in this case) for all deploy objects
607-
self.set_application_config(release)
608607
apply_tasks(tasks)
609608
except Exception as e:
610609
err = f'(changed volume mount for {volume}: {e}'
@@ -620,21 +619,18 @@ def _deploy(self, deploys, structure, prev_release,
620619
self._check_deployment_in_progress(deploys, release, force_deploy)
621620

622621
try:
623-
# create the application config in k8s (secret in this case) for all deploy objects
624-
self.set_application_config(release)
625-
626-
# gather all proc types to be deployed
627-
tasks = [
628-
functools.partial(
622+
tasks = []
623+
for scale_type, kwargs in deploys.items():
624+
self.set_application_config(release, scale_type)
625+
tasks.append(functools.partial(
629626
self.scheduler().deploy,
630627
namespace=self.id,
631628
name=self._get_job_id(scale_type, release.canary),
632629
image=release.get_deploy_image(scale_type),
633630
command=release.get_deploy_command(scale_type),
634631
args=release.get_deploy_args(scale_type),
635632
**kwargs
636-
) for scale_type, kwargs in deploys.items()
637-
]
633+
))
638634
try:
639635
apply_tasks(tasks)
640636
except KubeException as e:
@@ -721,6 +717,8 @@ def _scale_pods(self, scale_types, release, app_settings):
721717
volume for volume in volumes if scale_type in volume.path.keys()]
722718
data = self._gather_app_settings(
723719
release, app_settings, scale_type, replicas, volumes=scale_type_volumes)
720+
# create the application config in k8s (secret in this case) for all deploy objects
721+
self.set_application_config(release, scale_type)
724722
# gather all proc types to be deployed
725723
tasks.append(
726724
functools.partial(
@@ -734,8 +732,6 @@ def _scale_pods(self, scale_types, release, app_settings):
734732
)
735733
)
736734
try:
737-
# create the application config in k8s (secret in this case) for all deploy objects
738-
self.set_application_config(release)
739735
apply_tasks(tasks)
740736
except Exception as e:
741737
err = '(scale): {}'.format(e)
@@ -912,7 +908,7 @@ def _scheduler_filter(self, **kwargs):
912908
labels.update({'version': version})
913909
return labels
914910

915-
def _build_env_vars(self, release):
911+
def _build_env_vars(self, release, procfile_type):
916912
"""
917913
Build a dict of env vars, setting default vars based on app type
918914
and then combining with the user set ones
@@ -935,9 +931,9 @@ def _build_env_vars(self, release):
935931
port = release.get_port()
936932
if port:
937933
default_env['PORT'] = port
938-
939934
# merge envs on top of default to make envs win
940935
default_env.update(release.config.values)
936+
default_env.update(release.config.typed_values.get(procfile_type, {}))
941937
return default_env
942938

943939
def _get_private_registry_config(self, image, registry=None):
@@ -999,7 +995,7 @@ def _gather_app_settings(self, release, app_settings, procfile_type, replicas, v
999995
Any global setting that can also be set per app goes here
1000996
"""
1001997

1002-
envs = self._build_env_vars(release)
998+
envs = self._build_env_vars(release, procfile_type)
1003999
# Obtain a limit plan that must exist, if raise error here, it must be a bug
10041000
config = self._set_default_limit(release.config, procfile_type)
10051001
limit_plan = LimitPlan.objects.get(id=config.limits.get(procfile_type))

rootfs/api/models/config.py

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ class Config(UuidAuditedModel):
1818
during runtime execution of the Application.
1919
"""
2020
procfile_fields = ("lifecycle_post_start", "lifecycle_pre_stop", "tags", "limits",
21-
"healthcheck", "termination_grace_period")
21+
"typed_values", "healthcheck", "termination_grace_period")
2222
all_diff_fields = ("values", "registry") + procfile_fields
2323

2424
owner = models.ForeignKey(User, on_delete=models.PROTECT)
2525
app = models.ForeignKey('App', on_delete=models.CASCADE)
2626
values = models.JSONField(default=dict, blank=True)
27+
typed_values = models.JSONField(default=dict, blank=True)
2728
lifecycle_post_start = models.JSONField(default=dict, blank=True)
2829
lifecycle_pre_stop = models.JSONField(default=dict, blank=True)
2930
tags = models.JSONField(default=dict, blank=True)
@@ -79,17 +80,9 @@ def save(self, **kwargs):
7980
'lifecycle_pre_stop', 'termination_grace_period']:
8081
data = getattr(previous_config, attr, {}).copy()
8182
new_data = getattr(self, attr, {}).copy()
82-
83-
# remove config keys if a null value is provided
84-
for key, value in new_data.items():
85-
if value is None:
86-
# error if unsetting non-existing key
87-
if key not in data:
88-
raise UnprocessableEntity('{} does not exist under {}'.format(key, attr)) # noqa
89-
data.pop(key)
90-
else:
91-
data[key] = value
83+
self._merge_data(attr, data, new_data)
9284
setattr(self, attr, data)
85+
self._set_typed_values(previous_config)
9386
self._set_limits(previous_config)
9487
self._set_healthcheck(previous_config)
9588
self._set_registry()
@@ -99,6 +92,18 @@ def save(self, **kwargs):
9992

10093
return super(Config, self).save(**kwargs)
10194

95+
def _merge_data(self, field, data, new_data):
96+
# remove config keys if a null value is provided
97+
for key, value in new_data.items():
98+
if value is None:
99+
# error if unsetting non-existing key
100+
if key not in data:
101+
raise UnprocessableEntity('{} does not exist under {}'.format(key, field)) # noqa
102+
data.pop(key)
103+
else:
104+
data[key] = value
105+
return data
106+
102107
def _set_registry(self):
103108
# lower case all registry options for consistency
104109
self.registry = {key.lower(): value for key, value in self.registry.copy().items()}
@@ -144,20 +149,13 @@ def _set_tags(self, previous_config):
144149
def _set_limits(self, previous_config):
145150
data = getattr(previous_config, 'limits', {}).copy()
146151
new_data = getattr(self, 'limits', {}).copy()
147-
# remove config keys if a null value is provided
152+
# check procfile
148153
for key, value in new_data.items():
149154
if value is None:
150-
# error if unsetting non-existing key
151-
if key not in data:
152-
raise UnprocessableEntity(
153-
'{} does not exist under {}'.format(key, 'limits'))
154155
if key in self.app.procfile_types:
155156
raise UnprocessableEntity(
156157
"the %s has already been used and cannot be deleted" % key)
157-
else:
158-
data.pop(key)
159-
else:
160-
data[key] = value
158+
self._merge_data('limits', data, new_data)
161159
setattr(self, 'limits', data)
162160

163161
def _set_healthcheck(self, previous_config):
@@ -168,17 +166,25 @@ def _set_healthcheck(self, previous_config):
168166
if value is None:
169167
# error if unsetting non-existing key
170168
if key not in data:
171-
raise UnprocessableEntity('{} does not exist under {}'.format(key, 'healthcheck')) # noqa
169+
raise UnprocessableEntity(
170+
'{} does not exist under {}'.format(key, 'healthcheck'))
172171
data.pop(key)
173172
else:
174-
for probeType, probe in value.items():
175-
if probe is None:
176-
# error if unsetting non-existing key
177-
if key not in data or probeType not in data[key].keys():
178-
raise UnprocessableEntity('{} does not exist under {}'.format(key, 'healthcheck')) # noqa
179-
data[key].pop(probeType)
180-
else:
181-
if key not in data:
182-
data[key] = {}
183-
data[key][probeType] = probe
173+
data[key] = self._merge_data('healthcheck', data.get(key, {}), value)
184174
setattr(self, 'healthcheck', data)
175+
176+
def _set_typed_values(self, previous_config):
177+
data = getattr(previous_config, 'typed_values', {}).copy()
178+
new_data = getattr(self, 'typed_values', {}).copy()
179+
# remove config keys if a null value is provided
180+
for procfile_type, values in new_data.items():
181+
if not values:
182+
# error if unsetting non-existing key
183+
if procfile_type not in data:
184+
raise UnprocessableEntity(
185+
'{} does not exist under {}'.format(procfile_type, 'typed_values'))
186+
data.pop(procfile_type)
187+
else:
188+
data[procfile_type] = self._merge_data(
189+
'typed_values', data.get(procfile_type, {}), values)
190+
setattr(self, 'typed_values', data)

rootfs/api/serializers/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ def to_representation(self, obj):
8989
continue
9090

9191
try:
92-
if self.convert_to_str:
92+
if isinstance(v, (dict, list)):
93+
self.to_representation(v)
94+
elif self.convert_to_str:
9395
obj[k] = str(v)
9496
except ValueError:
9597
obj[k] = v
@@ -236,6 +238,7 @@ class ConfigSerializer(serializers.ModelSerializer):
236238
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
237239
owner = serializers.ReadOnlyField(source='owner.username')
238240
values = JSONFieldSerializer(required=False, binary=True)
241+
typed_values = JSONFieldSerializer(required=False, binary=True)
239242
limits = JSONFieldSerializer(required=False, binary=True)
240243
lifecycle_post_start = JSONFieldSerializer(required=False, binary=True)
241244
lifecycle_pre_stop = JSONFieldSerializer(required=False, binary=True)

rootfs/api/tests/test_app.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.test.utils import override_settings
1717
from rest_framework.authtoken.models import Token
1818

19-
from api.models.app import App
19+
from api.models.app import App, PROCFILE_TYPE_WEB
2020
from api.models.config import Config
2121
from scheduler import KubeException, KubeHTTPException
2222

@@ -585,14 +585,14 @@ def test_build_env_vars(self, mock_requests):
585585
app = App.objects.create(owner=self.user)
586586
# Make sure an exception is raised when calling without a build available
587587
with self.assertRaises(DryccException):
588-
app._build_env_vars(app.release_set.latest())
588+
app._build_env_vars(app.release_set.latest(), PROCFILE_TYPE_WEB)
589589
data = {'image': 'autotest/example', 'stack': 'heroku-18'}
590590
url = "/v2/apps/{app.id}/builds".format(**locals())
591591
response = self.client.post(url, data)
592592
self.assertEqual(response.status_code, 201, response.data)
593593
time_created = app.release_set.latest().created
594594
self.assertEqual(
595-
app._build_env_vars(app.release_set.latest()),
595+
app._build_env_vars(app.release_set.latest(), PROCFILE_TYPE_WEB),
596596
{
597597
'DRYCC_APP': app.id,
598598
'WORKFLOW_RELEASE': 'v2',
@@ -607,7 +607,7 @@ def test_build_env_vars(self, mock_requests):
607607
self.assertEqual(response.status_code, 201, response.data)
608608
time_created = app.release_set.latest().created
609609
self.assertEqual(
610-
app._build_env_vars(app.release_set.latest()),
610+
app._build_env_vars(app.release_set.latest(), PROCFILE_TYPE_WEB),
611611
{
612612
'DRYCC_APP': app.id,
613613
'WORKFLOW_RELEASE': 'v3',

rootfs/api/tests/test_config.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,10 @@ def test_response_data(self, mock_requests):
173173
body = {'values': json.dumps({'PORT': '5000'})}
174174
response = self.client.post(url, body)
175175
for key in response.data:
176-
self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values', 'limits',
177-
'tags', 'registry', 'healthcheck', 'lifecycle_post_start',
178-
'lifecycle_pre_stop', 'termination_grace_period'])
176+
self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values',
177+
'typed_values', 'limits', 'tags', 'registry', 'healthcheck',
178+
'lifecycle_post_start', 'lifecycle_pre_stop',
179+
'termination_grace_period'])
179180
expected = {
180181
'owner': self.user.username,
181182
'app': app_id,
@@ -205,8 +206,9 @@ def test_response_data_types_converted(self, mock_requests):
205206
self.assertEqual(response.status_code, 201, response.data)
206207
for key in response.data:
207208
self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values', 'limits',
208-
'tags', 'registry', 'healthcheck', 'lifecycle_post_start',
209-
'lifecycle_pre_stop', 'termination_grace_period'])
209+
'typed_values', 'tags', 'registry', 'healthcheck',
210+
'lifecycle_post_start', 'lifecycle_pre_stop',
211+
'termination_grace_period'])
210212
expected = {
211213
'owner': self.user.username,
212214
'app': app_id,
@@ -586,3 +588,33 @@ def test_set_config_limits_run(self, *args, **kwargs):
586588
self.assertEqual(response.status_code, 200, response.data)
587589
expect = {'run': 'std1.large.c2m4', 'web': 'std1.large.c2m4', 'worker': 'std1.large.c1m1'}
588590
self.assertEqual(expect, response.json()["limits"], response.data)
591+
592+
def test_config_set_typed_values(self, mock_requests):
593+
"""
594+
Test that config sets on the same key function properly
595+
"""
596+
app_id = self.create_app()
597+
url = f"/v2/apps/{app_id}/config"
598+
599+
# set an initial config value
600+
body = {'typed_values': {'web': {'PORT': '5000'}}}
601+
response = self.client.post(url, body)
602+
self.assertEqual(response.status_code, 201, response.data)
603+
self.assertIn('PORT', response.data['typed_values']['web'])
604+
605+
# reset same config value
606+
body = {'typed_values': {'web': {'PORT': '5001'}}}
607+
response = self.client.post(url, body)
608+
self.assertEqual(response.status_code, 201, response.data)
609+
self.assertIn('PORT', response.data['typed_values']['web'])
610+
self.assertEqual(response.data['typed_values']['web']['PORT'], '5001')
611+
# unset PORT
612+
body = {'typed_values': {'web': {'PORT': None}}}
613+
response = self.client.post(url, body)
614+
self.assertEqual(response.status_code, 201, response.data)
615+
self.assertEqual(response.data['typed_values']['web'], {}, response.data)
616+
# unset web
617+
body = {'typed_values': {'web': None}}
618+
response = self.client.post(url, body)
619+
self.assertEqual(response.status_code, 201, response.data)
620+
self.assertEqual(response.data['typed_values'], {}, response.data)

rootfs/scheduler/resources/pod.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ def _set_container(self, namespace, container_name, data, **kwargs):
216216

217217
if env:
218218
# map application configuration (env secret) to env vars
219-
secret_name = "{}-{}-env".format(namespace, kwargs.get('version'))
219+
secret_name = "{}-{}-{}-env".format(
220+
namespace, kwargs.get('app_type'), kwargs.get('version'))
220221
for key in env.keys():
221222
item = {
222223
"name": key,

0 commit comments

Comments
 (0)