Skip to content

Commit 180ad9b

Browse files
authored
feat(controller): add fetch ptypes and events
1 parent c6f785a commit 180ad9b

18 files changed

Lines changed: 548 additions & 138 deletions

File tree

charts/controller/templates/controller-clusterrole.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ rules:
1616
- apiGroups: [""]
1717
resources: ["nodes"]
1818
verbs: ["get", "list"]
19-
- apiGroups: [""]
19+
- apiGroups: ["events.k8s.io"]
2020
resources: ["events"]
2121
verbs: ["list", "create"]
2222
- apiGroups: [""]

rootfs/api/models/app.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,107 @@ def list_pods(self, *args, **kwargs):
427427
self.log(err, logging.ERROR)
428428
raise ServiceUnavailable(err) from e
429429

430+
def delete_pod(self, **kwargs):
431+
"""Used to list basic information about pods running for a given application"""
432+
pod_name = kwargs.get('pod_name')
433+
try:
434+
# make sure the pod is manageed by drycc
435+
pod = self.scheduler().pod.get(self.id, pod_name).json()
436+
if pod['metadata']['labels'].get("heritage") == "drycc":
437+
self.scheduler().pod.delete(self.id, pod_name)
438+
except KubeHTTPException as e:
439+
# Sometimes k8s will manage to remove the pod from under us
440+
if e.response.status_code != 404:
441+
raise e
442+
443+
def describe_deployment(self, deployment_name):
444+
result = []
445+
try:
446+
deployment = self.scheduler().deployment.get(self.id, deployment_name).json()
447+
for container in deployment["spec"]["template"]['spec']["containers"]:
448+
limits = container.get("resources", {}).get("limits", {})
449+
result.append({
450+
"container": container["name"],
451+
"image": container["image"],
452+
"command": container.get("command", []),
453+
"args": container.get("args", []),
454+
"liveness_probe": container.get("livenessProbe", {}),
455+
"readiness_probe": container.get("readinessProbe", {}),
456+
"limits": limits,
457+
"volume_mounts": container.get("volumeMounts", []),
458+
})
459+
except KubeHTTPException as e:
460+
if e.response.status_code != 404:
461+
raise e
462+
return result
463+
464+
def list_deployments(self, *args, **kwargs):
465+
"""Used to list basic information about deployments running for a given application"""
466+
try:
467+
labels = self._scheduler_filter(**kwargs)
468+
# in case a singular deployment is requested
469+
if 'name' in kwargs:
470+
deployments = [self.scheduler().deployment.get(self.id, kwargs['name']).json()]
471+
else:
472+
deployments = self.scheduler().deployment.get(self.id, labels=labels).json()['items'] # noqa
473+
if not deployments:
474+
deployments = []
475+
data = []
476+
for p in deployments:
477+
labels = p['spec']['template']['metadata']['labels']
478+
if p['metadata']['creationTimestamp']:
479+
started = p['metadata']['creationTimestamp']
480+
else:
481+
started = str(
482+
datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT))
483+
item = {
484+
'name': labels['type'],
485+
'release': labels['version'],
486+
'ready': "%s/%s" % (
487+
p["status"]["readyReplicas"],
488+
p["status"]["replicas"],
489+
),
490+
'up_to_date': p["status"]["updatedReplicas"],
491+
'available_replicas': p["status"]["availableReplicas"],
492+
'started': started
493+
}
494+
data.append(item)
495+
# sorting so latest start date is first
496+
data.sort(key=lambda x: x['started'], reverse=True)
497+
return data
498+
except KubeHTTPException:
499+
pass
500+
except Exception as e:
501+
err = '(list deployments): {}'.format(e)
502+
self.log(err, logging.ERROR)
503+
raise ServiceUnavailable(err) from e
504+
505+
def list_events(self, ref_kind, ref_name, *args, **kwargs):
506+
try:
507+
fields = {
508+
"regarding.kind": ref_kind,
509+
"regarding.name": ref_name
510+
}
511+
kwargs["fields"] = fields
512+
events = self.scheduler().events.get(self.id, **kwargs).json()['items'] # noqa
513+
data = []
514+
for e in events:
515+
item = {
516+
'reason': e['reason'],
517+
'message': e['note'],
518+
'created': e['metadata']['creationTimestamp']
519+
}
520+
data.append(item)
521+
# sorting so latest start date is first
522+
data.sort(key=lambda x: x['created'], reverse=False)
523+
return data
524+
except KubeHTTPException:
525+
pass
526+
except Exception as e:
527+
err = '(list event): {}'.format(e)
528+
self.log(err, logging.ERROR)
529+
raise ServiceUnavailable(err) from e
530+
430531
def autoscale(self, proc_type, autoscale):
431532
"""
432533
Set autoscale rules for the application

rootfs/api/serializers/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,27 @@ def to_representation(self, obj):
540540
return obj
541541

542542

543+
class EventSerializer(serializers.BaseSerializer):
544+
reason = serializers.CharField()
545+
message = serializers.CharField()
546+
created = serializers.DateTimeField(required=False)
547+
548+
def to_representation(self, obj):
549+
return obj
550+
551+
552+
class PtypesSerializer(serializers.BaseSerializer):
553+
name = serializers.CharField(required=False)
554+
release = serializers.CharField()
555+
ready = serializers.CharField()
556+
up_to_date = serializers.CharField()
557+
available_replicas = serializers.CharField(required=False)
558+
started = serializers.DateTimeField(required=False)
559+
560+
def to_representation(self, obj):
561+
return obj
562+
563+
543564
class AppSettingsSerializer(serializers.ModelSerializer):
544565
"""Serialize a :class:`~api.models.appsettings.AppSettings` model."""
545566

rootfs/api/tasks.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,23 @@ def restart_app(app, **kwargs):
7979
signals.request_finished.send(sender=task_id)
8080

8181

82+
@shared_task(
83+
autoretry_for=(ServiceUnavailable, ),
84+
retry_jitter=True,
85+
retry_kwargs={'max_retries': 3}
86+
)
87+
def delete_pod(app, **kwargs):
88+
task_id = uuid.uuid4().hex
89+
signals.request_started.send(sender=task_id)
90+
try:
91+
app.delete_pod(**kwargs)
92+
except Exception as e:
93+
signals.got_request_exception.send(sender=task_id)
94+
raise e
95+
else:
96+
signals.request_finished.send(sender=task_id)
97+
98+
8299
@shared_task(
83100
autoretry_for=(ServiceUnavailable, ),
84101
retry_jitter=True,

rootfs/api/tests/test_build.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ def test_build_forgotten_procfile(self, mock_requests):
244244
self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
245245

246246
# scale worker
247-
url = f"/v2/apps/{app_id}/scale"
247+
url = f"/v2/apps/{app_id}/ptypes/scale"
248248
body = {'worker': 1}
249249
response = self.client.post(url, body)
250250
self.assertEqual(response.status_code, 204, response.data)
@@ -304,7 +304,7 @@ def test_build_no_remove_process(self, mock_requests):
304304
self.assertEqual(response.status_code, 201, response.data)
305305

306306
# scale worker
307-
url = f"/v2/apps/{app_id}/scale"
307+
url = f"/v2/apps/{app_id}/ptypes/scale"
308308
body = {'worker': 1}
309309
response = self.client.post(url, body)
310310
self.assertEqual(response.status_code, 204, response.data)
@@ -340,7 +340,7 @@ def test_build_no_remove_process(self, mock_requests):
340340
self.assertEqual(response.json()['structure'], {'web': 1, 'worker': 1})
341341

342342
# scale worker to make sure no info was lost
343-
url = f"/v2/apps/{app_id}/scale"
343+
url = f"/v2/apps/{app_id}/ptypes/scale"
344344
body = {'worker': 2} # bump from 1 to 2
345345
response = self.client.post(url, body)
346346
self.assertEqual(response.status_code, 204, response.data)
@@ -376,7 +376,7 @@ def test_build_forgotten_procfile_reject(self, mock_requests):
376376
self.assertEqual(response.status_code, 201, response.data)
377377

378378
# scale worker
379-
url = f"/v2/apps/{app_id}/scale"
379+
url = f"/v2/apps/{app_id}/ptypes/scale"
380380
body = {'worker': 1}
381381
response = self.client.post(url, body)
382382
self.assertEqual(response.status_code, 204, response.data)
@@ -472,7 +472,7 @@ def test_new_build_does_not_scale_up_automatically(self, mock_requests):
472472
self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
473473

474474
# scale to zero
475-
url = f"/v2/apps/{app_id}/scale"
475+
url = f"/v2/apps/{app_id}/ptypes/scale"
476476
body = {'web': 0}
477477
response = self.client.post(url, body)
478478
self.assertEqual(response.status_code, 204, response.data)

rootfs/api/tests/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ def test_unset_limits_error(self, mock_requests):
507507
self.assertEqual(str(response.data["detail"]), "no-exists does not exist under limits")
508508
# scale up
509509
body = {'web': 3}
510-
response = self.client.post(f"/v2/apps/{app_id}/scale", body)
510+
response = self.client.post(f"/v2/apps/{app_id}/ptypes/scale", body)
511511
self.assertEqual(response.status_code, 204, response.data)
512512

513513
body = {

rootfs/api/tests/test_event.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Unit tests for the Drycc api app.
4+
5+
Run the tests with "./manage.py test api"
6+
"""
7+
from django.contrib.auth import get_user_model
8+
from django.core.cache import cache
9+
from django.conf import settings
10+
11+
from api.models.app import App
12+
13+
from api.tests import adapter, DryccTransactionTestCase
14+
import requests_mock
15+
16+
User = get_user_model()
17+
18+
19+
@requests_mock.Mocker(real_http=True, adapter=adapter)
20+
class EventTest(DryccTransactionTestCase):
21+
"""Tests setting and updating config values"""
22+
23+
fixtures = ['tests.json']
24+
25+
def setUp(self):
26+
self.user = User.objects.get(username='autotest')
27+
self.token = self.get_or_create_token(self.user)
28+
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
29+
30+
url = '/v2/apps'
31+
response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
32+
self.assertEqual(response.status_code, 201, response.data)
33+
self.app = App.objects.all()[0]
34+
35+
def tearDown(self):
36+
# Restore default tags to empty string
37+
settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
38+
# make sure every test has a clean slate for k8s mocking
39+
cache.clear()
40+
41+
def build_deploy(self, app_id):
42+
# post a new build with procfile
43+
url = "/v2/apps/{app_id}/builds".format(app_id=app_id)
44+
body = {
45+
'image': 'autotest/example',
46+
'sha': 'a'*40,
47+
'stack': 'heroku-18',
48+
'procfile': {
49+
'web': 'node server.js',
50+
'worker': 'node worker.js'
51+
}
52+
}
53+
response = self.client.post(url, body)
54+
self.assertEqual(response.status_code, 201, response.data)
55+
56+
def test_events(self, mock_requests):
57+
"""
58+
Test that config is auto-created for a new app and that
59+
config can be updated using a PATCH
60+
"""
61+
app_id = self.create_app()
62+
self.build_deploy(app_id)
63+
64+
# list events of pod
65+
url = f"/v2/apps/{app_id}/pods"
66+
response = self.client.get(url)
67+
self.assertEqual(response.status_code, 200, response.data)
68+
pod_name = response.data['results'][0]["name"]
69+
response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
70+
url = f"/v2/apps/{app_id}/events"
71+
response = self.client.get(url, {"pod_name": pod_name})
72+
self.assertEqual(response.status_code, 200, response.data)
73+
74+
# list events of deployment
75+
url = f"/v2/apps/{app_id}/events"
76+
response = self.client.get(url, {"ptype_name": f"{app_id}-web"})
77+
self.assertEqual(response.status_code, 200, response.data)

0 commit comments

Comments
 (0)