Skip to content

Commit 2acdd2a

Browse files
author
Gabriel Monroy
committed
new hooks API for push/build, deprecate Build.push()
1 parent 37a3fe6 commit 2acdd2a

5 files changed

Lines changed: 149 additions & 98 deletions

File tree

api/models.py

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -765,35 +765,6 @@ class Meta:
765765
def __str__(self):
766766
return "{0}-{1}".format(self.app.id, self.sha[:7])
767767

768-
@classmethod
769-
def push(cls, push):
770-
"""Process a push from a local Git server.
771-
772-
Creates a new Build and returns the application's
773-
databag for processing by the git-receive hook
774-
"""
775-
# SECURITY:
776-
# we assume the first part of the ssh key name
777-
# is the authenticated user because we trust gitosis
778-
username = push.pop('username').split('_')[0]
779-
# retrieve the user and app instances
780-
user = User.objects.get(username=username)
781-
app = App.objects.get(id=push.pop('app'))
782-
# merge the push with the required model instances
783-
push['owner'] = user
784-
push['app'] = app
785-
# create the build
786-
new_build = cls.objects.create(**push)
787-
# send a release signal
788-
release_signal.send(sender=user, build=new_build, app=app, user=user)
789-
# see if we need to scale an initial web container
790-
if len(app.formation.node_set.filter(layer__runtime=True)) > 0 and \
791-
len(app.container_set.filter(type='web')) < 1:
792-
# scale an initial web containers
793-
Container.objects.scale(app, {'web': 1})
794-
# publish and converge the application
795-
return app.converge()
796-
797768

798769
@python_2_unicode_compatible
799770
class Release(UuidAuditedModel):

api/tests/test_build.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -96,33 +96,6 @@ def test_build(self):
9696
self.assertEqual(self.client.patch(url).status_code, 405)
9797
self.assertEqual(self.client.delete(url).status_code, 405)
9898

99-
def test_build_push(self):
100-
"""
101-
Simlulate a git push creating a new Build object
102-
"""
103-
formation_id = 'autotest'
104-
url = '/api/formations/{formation_id}/layers'.format(**locals())
105-
body = {'id': 'runtime', 'flavor': 'autotest', 'runtime': True, 'proxy': True}
106-
response = self.client.post(url, json.dumps(body), content_type='application/json')
107-
self.assertEqual(response.status_code, 201)
108-
url = '/api/formations/{formation_id}/scale'.format(**locals())
109-
body = {'runtime': 2}
110-
response = self.client.post(url, json.dumps(body), content_type='application/json')
111-
self.assertEqual(response.status_code, 200)
112-
url = '/api/apps'
113-
body = {'formation': formation_id}
114-
response = self.client.post(url, json.dumps(body), content_type='application/json')
115-
self.assertEqual(response.status_code, 201)
116-
app_id = response.data['id']
117-
push = {'username': 'autotest', 'app': app_id}
118-
databag = Build.push(push)
119-
self.assertIn('release', databag)
120-
self.assertIn('version', databag['release'])
121-
self.assertIn('containers', databag)
122-
self.assertIn('web', databag['containers'])
123-
self.assertIn('1', databag['containers']['web'])
124-
self.assertEqual(databag['containers']['web']['1'], 'up')
125-
12699
def test_build_str(self):
127100
"""Test the text representation of a build."""
128101
url = '/api/apps'
Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import unicode_literals
88

99
import json
10+
import uuid
1011

1112
from django.test import TestCase
1213
from django.test.utils import override_settings
@@ -15,9 +16,9 @@
1516

1617

1718
@override_settings(CELERY_ALWAYS_EAGER=True)
18-
class PushTest(TestCase):
19+
class HookTest(TestCase):
1920

20-
"""Tests pushes into the push system"""
21+
"""Tests API hooks used to trigger actions from external components"""
2122

2223
fixtures = ['tests.json']
2324

@@ -42,10 +43,8 @@ def setUp(self):
4243
content_type='application/json')
4344
self.assertEqual(response.status_code, 201)
4445

45-
def test_push(self):
46-
"""
47-
Test that a user can push into the system
48-
"""
46+
def test_push_hook(self):
47+
"""Test creating a Push via the API"""
4948
url = '/api/apps'
5049
body = {'formation': 'autotest'}
5150
response = self.client.post(url, json.dumps(body), content_type='application/json')
@@ -56,12 +55,12 @@ def test_push(self):
5655
'sha': 'df1e628f2244b73f9cdf944f880a2b3470a122f4',
5756
'fingerprint': '88:25:ed:67:56:91:3d:c6:1b:7f:42:c6:9b:41:24:80',
5857
'receive_user': 'autotest',
59-
'receive_repo': 'repo.git',
58+
'receive_repo': '{app_id}'.format(**locals()),
6059
'ssh_connection': '10.0.1.10 50337 172.17.0.143 22',
61-
'ssh_original_command': "git-receive-pack 'repo.git'",
60+
'ssh_original_command': "git-receive-pack '{app_id}.git'".format(**locals()),
6261
}
6362
# post a request without the auth header
64-
url = "/api/apps/{app_id}/push".format(**locals())
63+
url = "/api/hooks/push".format(**locals())
6564
response = self.client.post(url, json.dumps(body), content_type='application/json')
6665
self.assertEqual(response.status_code, 403)
6766
# now try with the builder key in the special auth header
@@ -73,7 +72,8 @@ def test_push(self):
7372
self.assertIn(k, response.data)
7473

7574
def test_push_abuse(self):
76-
# create a legit app
75+
"""Test a user pushing to an unauthorized application"""
76+
# create a legit app as "autotest"
7777
url = '/api/apps'
7878
body = {'formation': 'autotest'}
7979
response = self.client.post(url, json.dumps(body), content_type='application/json')
@@ -98,12 +98,68 @@ def test_push_abuse(self):
9898
'sha': 'df1e628f2244b73f9cdf944f880a2b3470a122f4',
9999
'fingerprint': '88:25:ed:67:56:91:3d:c6:1b:7f:42:c6:9b:41:24:99',
100100
'receive_user': 'eviluser',
101-
'receive_repo': 'repo.git',
101+
'receive_repo': '{app_id}'.format(**locals()),
102102
'ssh_connection': '10.0.1.10 50337 172.17.0.143 22',
103-
'ssh_original_command': "git-receive-pack 'repo.git'",
103+
'ssh_original_command': "git-receive-pack '{app_id}.git'".format(**locals()),
104104
}
105105
# try to push as "eviluser"
106-
url = "/api/apps/{app_id}/push".format(**locals())
106+
url = "/api/hooks/push".format(**locals())
107107
response = self.client.post(url, json.dumps(body), content_type='application/json',
108108
HTTP_X_DEIS_BUILDER_AUTH=settings.BUILDER_KEY)
109109
self.assertEqual(response.status_code, 403)
110+
111+
def test_build_hook(self):
112+
"""Test creating a Build via the API"""
113+
formation_id = 'autotest'
114+
url = '/api/formations/{formation_id}/layers'.format(**locals())
115+
body = {'id': 'runtime', 'flavor': 'autotest', 'runtime': True, 'proxy': True}
116+
response = self.client.post(url, json.dumps(body), content_type='application/json')
117+
self.assertEqual(response.status_code, 201)
118+
url = '/api/formations/{formation_id}/scale'.format(**locals())
119+
body = {'runtime': 2}
120+
response = self.client.post(url, json.dumps(body), content_type='application/json')
121+
self.assertEqual(response.status_code, 200)
122+
url = '/api/apps'
123+
body = {'formation': formation_id}
124+
response = self.client.post(url, json.dumps(body), content_type='application/json')
125+
self.assertEqual(response.status_code, 201)
126+
app_id = response.data['id']
127+
build = {'username': 'autotest', 'app': app_id}
128+
url = '/api/hooks/builds'.format(**locals())
129+
sha, checksum = uuid.uuid4().hex, uuid.uuid4().hex
130+
body = {'receive_user': 'autotest',
131+
'receive_repo': app_id,
132+
'sha': sha,
133+
'checksum': checksum,
134+
'procfile': {'web': 'node server.js'},
135+
'config': {'PATH': '/usr/local/bin:/usr/bin:/usr/sbin'},
136+
'url': 'http://deis-controller.local/slugs/{app_id}-{sha}.tar.gz'.format(**locals()),
137+
'size': 12345}
138+
# post the build without a session
139+
self.assertIsNone(self.client.logout())
140+
response = self.client.post(url, json.dumps(body), content_type='application/json')
141+
self.assertEqual(response.status_code, 403)
142+
# post the build with the builder auth key
143+
response = self.client.post(url, json.dumps(body), content_type='application/json',
144+
HTTP_X_DEIS_BUILDER_AUTH=settings.BUILDER_KEY)
145+
self.assertEqual(response.status_code, 201)
146+
build = response.data
147+
self.assertIn('sha', response.data)
148+
self.assertIn('procfile', response.data)
149+
procfile = json.loads(response.data['procfile'])
150+
self.assertIn('web', procfile)
151+
self.assertEqual(procfile['web'], 'node server.js')
152+
# calculate the databag
153+
self.assertTrue(
154+
self.client.login(username='autotest', password='password'))
155+
url = '/api/apps/{app_id}/calculate'.format(**locals())
156+
response = self.client.post(url)
157+
self.assertEqual(response.status_code, 200)
158+
databag = response.data
159+
self.assertIn('release', databag)
160+
self.assertIn('version', databag['release'])
161+
self.assertIn('containers', databag)
162+
self.assertIn('web', databag['containers'])
163+
self.assertIn('1', databag['containers']['web'])
164+
self.assertEqual(databag['containers']['web']['1'], 'up')
165+

api/urls.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,6 @@
189189
Application Release Components
190190
------------------------------
191191
192-
.. http:post:: /api/apps/(string:id)/push/
193-
194-
Create a new :class:`~api.models.Push`.
195-
196192
.. http:get:: /api/apps/(string:id)/config/
197193
198194
List all :class:`~api.models.Config`\s.
@@ -282,6 +278,18 @@
282278
Create a new app permission.
283279
284280
281+
API Hooks
282+
=========
283+
284+
.. http:post:: /api/hooks/push/
285+
286+
Create a new :class:`~api.models.Push`.
287+
288+
.. http:post:: /api/hooks/build/
289+
290+
Create a new :class:`~api.models.Build`.
291+
292+
285293
Nodes
286294
=====
287295
@@ -409,8 +417,6 @@
409417
url(r'^formations/?',
410418
views.FormationViewSet.as_view({'get': 'list', 'post': 'create'})),
411419
# application release components
412-
url(r'^apps/(?P<id>[-_\w]+)/push/?',
413-
views.AppPushViewSet.as_view({'post': 'create'})),
414420
url(r'^apps/(?P<id>[-_\w]+)/config/?',
415421
views.AppConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
416422
url(r'^apps/(?P<id>[-_\w]+)/builds/(?P<uuid>[-_\w]+)/?',
@@ -449,6 +455,11 @@
449455
views.AppViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
450456
url(r'^apps/?',
451457
views.AppViewSet.as_view({'get': 'list', 'post': 'create'})),
458+
# hooks
459+
url(r'^hooks/push/?',
460+
views.PushHookViewSet.as_view({'post': 'create'})),
461+
url(r'^hooks/build/?',
462+
views.BuildHookViewSet.as_view({'post': 'create'})),
452463
# nodes
453464
url(r'^nodes/(?P<node>[-_\w]+)/converge/?',
454465
views.NodeViewSet.as_view({'post': 'converge'})),

api/views.py

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -552,29 +552,6 @@ def get_object(self, *args, **kwargs):
552552
raise PermissionDenied()
553553

554554

555-
class AppPushViewSet(viewsets.ModelViewSet):
556-
"""RESTful views for :class:`~api.models.Push`."""
557-
558-
model = models.Push
559-
serializer_class = serializers.PushSerializer
560-
561-
permission_classes = (HasBuilderAuth,)
562-
563-
def pre_save(self, obj):
564-
# SECURITY: we trust the receive_user field to map to the push owner
565-
obj.owner = self.request.DATA['owner']
566-
567-
def create(self, request, *args, **kwargs):
568-
request._data = request.DATA.copy()
569-
app = request.DATA['app'] = get_object_or_404(models.App, id=self.kwargs['id'])
570-
# check the user is authorized for this app
571-
user = request.DATA['owner'] = get_object_or_404(
572-
User, username=self.request.DATA['receive_user'])
573-
if user == app.owner or user in get_users_with_perms(app):
574-
return super(AppPushViewSet, self).create(request, *args, **kwargs)
575-
raise PermissionDenied()
576-
577-
578555
class AppConfigViewSet(BaseAppViewSet):
579556
"""RESTful views for :class:`~api.models.Config`."""
580557

@@ -682,3 +659,66 @@ def get_object(self, *args, **kwargs):
682659
qs = self.get_queryset(**kwargs)
683660
obj = qs.get(num=self.kwargs['num'])
684661
return obj
662+
663+
664+
class BaseHookViewSet(viewsets.ModelViewSet):
665+
666+
permission_classes = (HasBuilderAuth,)
667+
668+
def pre_save(self, obj):
669+
# SECURITY: we trust the username field to map to the owner
670+
obj.owner = self.request.DATA['owner']
671+
672+
673+
class PushHookViewSet(BaseHookViewSet):
674+
"""API hook to create new :class:`~api.models.Push`"""
675+
676+
model = models.Push
677+
serializer_class = serializers.PushSerializer
678+
679+
def create(self, request, *args, **kwargs):
680+
app = get_object_or_404(models.App, id=request.DATA['receive_repo'])
681+
user = get_object_or_404(
682+
User, username=request.DATA['receive_user'])
683+
# check the user is authorized for this app
684+
if user == app.owner or user in get_users_with_perms(app):
685+
request._data = request.DATA.copy()
686+
request.DATA['app'] = app
687+
request.DATA['owner'] = user
688+
return super(PushHookViewSet, self).create(request, *args, **kwargs)
689+
raise PermissionDenied()
690+
691+
692+
class BuildHookViewSet(BaseHookViewSet):
693+
"""API hook to create new :class:`~api.models.Build`"""
694+
695+
model = models.Build
696+
serializer_class = serializers.BuildSerializer
697+
698+
def create(self, request, *args, **kwargs):
699+
app = get_object_or_404(models.App, id=request.DATA['receive_repo'])
700+
user = get_object_or_404(
701+
User, username=request.DATA['receive_user'])
702+
# check the user is authorized for this app
703+
if user == app.owner or user in get_users_with_perms(app):
704+
request._data = request.DATA.copy()
705+
request.DATA['app'] = app
706+
request.DATA['owner'] = user
707+
return super(BuildHookViewSet, self).create(request, *args, **kwargs)
708+
raise PermissionDenied()
709+
710+
def post_save(self, obj, created=False):
711+
if created:
712+
# create a new release
713+
models.release_signal.send(
714+
sender=self, build=obj, app=obj.app,
715+
user=obj.owner)
716+
models.release_signal.send(sender=self, build=obj, app=obj.app, user=obj.owner)
717+
# see if we need to scale an initial web container
718+
app = obj.app
719+
if len(app.formation.node_set.filter(layer__runtime=True)) > 0 and \
720+
len(app.container_set.filter(type='web')) < 1:
721+
# scale an initial web containers
722+
models.Container.objects.scale(app, {'web': 1})
723+
# publish and converge the application
724+
app.converge()

0 commit comments

Comments
 (0)