Skip to content

Commit 8b2a2f5

Browse files
committed
O hai, Orchestra!
1 parent 5e8be62 commit 8b2a2f5

File tree

187 files changed

+32102
-1
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

187 files changed

+32102
-1
lines changed

.gitignore

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Byte-compiled / optimized / DLL files
2+
*.py[cod]
3+
*~
4+
__pycache__/
5+
6+
# Sqlite
7+
*.sqlite3
8+
9+
# Mac related
10+
.DS_Store
11+
12+
# Emacs temp files
13+
[#]*[#]
14+
.\#*
15+
16+
# Coverage data
17+
coverage_annotations/
18+
.coverage
19+
20+
# Database backups data
21+
backup.sql
22+
23+
# Compressed staticfiles
24+
/orchestra/staticfiles

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
same "printed page" as the copyright notice for easier
187187
identification within third-party archives.
188188

189-
Copyright {yyyy} {name of copyright owner}
189+
Copyright 2015 Unlimited Labs, Inc.
190190

191191
Licensed under the Apache License, Version 2.0 (the "License");
192192
you may not use this file except in compliance with the License.

NOTICE

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Orchestra
2+
Copyright 2015 Unlimited Labs, Inc.
3+
4+
We based the Orchestra html/css/js theme off of Dashgum (http://www.blacktie.co/demo/dashgumfree/)
5+
Creative commons license with attribution (http://creativecommons.org/licenses/by/3.0/)
6+
Source: http://www.blacktie.co/about-themes/

beanstalk_dispatch/README.md

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
The Beanstalk dispatcher is a Django app that runs functions that have
2+
been scheduled to run an AWS SQS queue and executes them on Elastic
3+
Beanstalk Worker machines that are listening to that queue.
4+
5+
## Configuration
6+
7+
To install:
8+
9+
* create an Elastic Beanstalk environment for an application
10+
that has the following two parameters in `settings.py`:
11+
12+
```python
13+
BEANSTALK_DISPATCH_SQS_KEY = 'your AWS key for accessing SQS'
14+
BEANSTALK_DISPATCH_SQS_SECRET = 'your AWS secret for accessing SQS'
15+
```
16+
17+
* add `beanstalk_dispatch` to settings.py's `INSTALLED_APPS`
18+
19+
* Add `url(r'^beanstalk_dispatch/',
20+
include('beanstalk_dispatch.urls')),` to your main `urls.py`
21+
22+
* Add `/beanstalk_dispatch/dispatcher` as the HTTP endpoint or your
23+
beanstalk worker configuration in the AWS console.
24+
25+
* Add a dispatch table. The dispatcher works by creating an HTTP
26+
endpoint that a local SQS/Beanstalk daemon POSTs requests to. That
27+
endpoint consults a `BEANSTALK_DISPATCH_TABLE`, which maps function
28+
names onto functions to run. Here's an example:
29+
30+
```python
31+
if os.environ.get('BEANSTALK_WORKER') == 'True':
32+
BEANSTALK_DISPATCH_TABLE = {
33+
'a_function_to_dispatch': ('some_package.beanstalk_tasks', 'the_name_of_the_function_in_the_module')}
34+
```
35+
36+
The first line is a check we have that ensures this type of machine
37+
should be a beanstalk worker. We set a BEANSTALK_WORKER environment
38+
variable to True in the environment's configuration only on our worker
39+
machines. This avoids other environments (e.g., our web servers) from
40+
serving as open proxies for running arbitrary code.
41+
42+
The second line is the dispatch table. It maps function name (e.g.,
43+
`a_function_to_dispatch`) to a module (e.g.,
44+
`some_package.beanstalk_tasks` and function in that module (e.g.,
45+
`the_name_of_the_function_in_the_module`).
46+
47+
48+
## Scheduling a function to run
49+
50+
The `beanstalk_dispatch.client.schedule_function` schedules a function
51+
to run on a given SQS queue. The function name you pass it must be a
52+
key in the `BEANSTALK_DISPATCH_TABLE`, and the queue name you pass it
53+
must be a queue for which a beanstalk worker is configured.

beanstalk_dispatch/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class BeanstalkDispatchError(Exception):
2+
pass
3+
4+
ARGS = 'args'
5+
FUNCTION = 'function'
6+
KWARGS = 'kwargs'

beanstalk_dispatch/admin.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Register your models here.

beanstalk_dispatch/client.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import boto.sqs
2+
3+
from beanstalk_dispatch.common import create_request_body
4+
from django.conf import settings
5+
6+
7+
def schedule_function(queue_name, function_name, *args, **kwargs):
8+
"""
9+
Schedule a function named `function_name` to be run by workers on
10+
the queue `queue_name` with *args and **kwargs as specified by that
11+
function.
12+
"""
13+
body = create_request_body(function_name, *args, **kwargs)
14+
connection = boto.connect_sqs(
15+
settings.BEANSTALK_DISPATCH_SQS_KEY,
16+
settings.BEANSTALK_DISPATCH_SQS_SECRET)
17+
queue = connection.get_queue(queue_name)
18+
if not queue:
19+
queue = connection.create_queue(queue_name)
20+
message = boto.sqs.message.Message()
21+
message.set_body(body)
22+
queue.write(message)

beanstalk_dispatch/common.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import json
2+
3+
from beanstalk_dispatch import ARGS
4+
from beanstalk_dispatch import FUNCTION
5+
from beanstalk_dispatch import KWARGS
6+
7+
8+
def create_request_body(function_name, *args, **kwargs):
9+
return json.dumps({FUNCTION: function_name, ARGS: args, KWARGS: kwargs})

beanstalk_dispatch/execution.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from beanstalk_dispatch import ARGS
2+
from beanstalk_dispatch import FUNCTION
3+
from beanstalk_dispatch import KWARGS
4+
from beanstalk_dispatch import BeanstalkDispatchError
5+
from django.conf import settings
6+
from importlib import import_module
7+
8+
9+
def execute_function(function_request):
10+
"""
11+
Given a request created by
12+
`beanstalk_dispatch.common.create_request_body`, executes the
13+
request. This function is to be run on a beanstalk worker.
14+
"""
15+
dispatch_table = getattr(settings, 'BEANSTALK_DISPATCH_TABLE', None)
16+
17+
if dispatch_table is None:
18+
raise BeanstalkDispatchError('No beanstalk dispatch table configured')
19+
for key in (FUNCTION, ARGS, KWARGS):
20+
if key not in function_request.keys():
21+
raise BeanstalkDispatchError(
22+
'Please provide a {} argument'.format(key))
23+
24+
module_name, function_name = (
25+
dispatch_table.get(function_request[FUNCTION], (None, None)))
26+
if module_name and function_name:
27+
# TODO(marcua): Catch import errors and rethrow them as
28+
# BeanstalkDispatchErrors.
29+
module = import_module(module_name)
30+
function = getattr(module, function_name)
31+
function(*function_request[ARGS], **function_request[KWARGS])
32+
else:
33+
raise BeanstalkDispatchError(
34+
'Requested function not found: {}'.format(
35+
function_request[FUNCTION]))

beanstalk_dispatch/migrations/__init__.py

Whitespace-only changes.

beanstalk_dispatch/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Create your models here.

beanstalk_dispatch/tests/__init__.py

Whitespace-only changes.
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import boto
2+
import json
3+
4+
from beanstalk_dispatch import ARGS
5+
from beanstalk_dispatch import FUNCTION
6+
from beanstalk_dispatch import KWARGS
7+
from beanstalk_dispatch.client import schedule_function
8+
from django.test import TestCase
9+
from django.test import override_settings
10+
from moto import mock_sqs
11+
12+
13+
@mock_sqs
14+
@override_settings(
15+
BEANSTALK_DISPATCH_SQS_KEY='', BEANSTALK_DISPATCH_SQS_SECRET='')
16+
class ClientTestCase(TestCase):
17+
18+
def setUp(self):
19+
self.queue_name = 'testing-queue'
20+
21+
def test_function_scheduling(self):
22+
# Check the message on the queue.
23+
sqs_connection = boto.connect_sqs('', '')
24+
sqs_connection.create_queue(self.queue_name)
25+
queue = sqs_connection.get_queue(self.queue_name)
26+
messages = queue.get_messages()
27+
self.assertEquals(len(messages), 0)
28+
29+
# Schedule a function.
30+
schedule_function(
31+
self.queue_name, 'a-function', '1', '2', kwarg1=1, kwarg2=2)
32+
33+
messages = queue.get_messages()
34+
self.assertEquals(len(messages), 1)
35+
36+
# For some reason, boto base64-encodes the messages, but moto does
37+
# not. Life.
38+
self.assertEquals(
39+
json.loads(messages[0].get_body()),
40+
{FUNCTION: 'a-function', ARGS: ['1', '2'], KWARGS: {
41+
'kwarg1': 1, 'kwarg2': 2}})
+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import json
2+
3+
from base64 import b64encode
4+
from beanstalk_dispatch import ARGS
5+
from beanstalk_dispatch import FUNCTION
6+
from beanstalk_dispatch import KWARGS
7+
from beanstalk_dispatch.common import create_request_body
8+
from django.core.urlresolvers import reverse
9+
from django.test import Client
10+
from django.test import TestCase
11+
from django.test import override_settings
12+
13+
# Don't log logger errors.
14+
import logging
15+
logging.disable(logging.CRITICAL)
16+
17+
18+
CALL_COUNTER = 0
19+
20+
21+
def counter_incrementer(first_arg, second_arg=None):
22+
global CALL_COUNTER
23+
CALL_COUNTER += first_arg
24+
if second_arg:
25+
CALL_COUNTER += second_arg
26+
27+
28+
DISPATCH_SETTINGS = {
29+
'BEANSTALK_DISPATCH_TABLE': {
30+
'the_counter': (
31+
'beanstalk_dispatch.tests.test_dispatcher',
32+
'counter_incrementer')}}
33+
34+
35+
class DispatcherTestCase(TestCase):
36+
37+
""" Test the server-side function dispatcher.
38+
39+
In these tests, we base64-encode every message we send to the server
40+
because this is what boto does.
41+
"""
42+
43+
def setUp(self):
44+
global CALL_COUNTER
45+
CALL_COUNTER = 0
46+
self.client = Client()
47+
self.url = reverse('beanstalk_dispatcher')
48+
49+
@override_settings(**DISPATCH_SETTINGS)
50+
def test_no_get(self):
51+
response = self.client.get(self.url)
52+
self.assertEquals(response.status_code, 405)
53+
54+
@override_settings(BEANSTALK_DISPATCH_TABLE=None)
55+
def test_no_dispatch(self):
56+
response = self.client.post(
57+
self.url, b64encode(
58+
create_request_body('some_func').encode('ascii')),
59+
content_type='application/json')
60+
self.assertEquals(response.status_code, 400)
61+
self.assertEquals(json.loads(response.content.decode()),
62+
{'message': 'No beanstalk dispatch table configured',
63+
'error': 400})
64+
65+
@override_settings(**DISPATCH_SETTINGS)
66+
def test_missing_function(self):
67+
response = self.client.post(
68+
self.url,
69+
b64encode(create_request_body('nonexistent_func').encode('ascii')),
70+
content_type='application/json')
71+
self.assertEquals(response.status_code, 400)
72+
self.assertEquals(
73+
json.loads(response.content.decode()),
74+
{'message': 'Requested function not found: nonexistent_func',
75+
'error': 400})
76+
77+
@override_settings(**DISPATCH_SETTINGS)
78+
def test_malformed_request(self):
79+
keys = {FUNCTION, ARGS, KWARGS}
80+
for missing_key in keys:
81+
request_body = {key: 'test' for key in
82+
keys - {missing_key}}
83+
response = self.client.post(
84+
self.url,
85+
b64encode(json.dumps(request_body).encode('ascii')),
86+
content_type='application/json')
87+
self.assertEquals(response.status_code, 400)
88+
self.assertEquals(json.loads(response.content.decode()), {
89+
'message': 'Please provide a {} argument'.format(missing_key),
90+
'error': 400})
91+
92+
@override_settings(**DISPATCH_SETTINGS)
93+
def test_both_args_kwargs(self):
94+
body = b64encode(
95+
create_request_body('the_counter', 1, second_arg=5)
96+
.encode('ascii'))
97+
response = self.client.post(self.url,
98+
body,
99+
content_type='application/json')
100+
self.assertEquals(response.status_code, 200)
101+
self.assertEquals(json.loads(response.content.decode()),
102+
{})
103+
self.assertEquals(CALL_COUNTER, 6)
104+
105+
@override_settings(**DISPATCH_SETTINGS)
106+
def test_just_args(self):
107+
body = b64encode(create_request_body('the_counter', 2).encode('ascii'))
108+
response = self.client.post(self.url,
109+
body,
110+
content_type='application/json')
111+
self.assertEquals(response.status_code, 200)
112+
self.assertEquals(json.loads(response.content.decode()),
113+
{})
114+
self.assertEquals(CALL_COUNTER, 2)

beanstalk_dispatch/urls.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.conf.urls import patterns
2+
from django.conf.urls import url
3+
from beanstalk_dispatch.views import dispatcher
4+
5+
urlpatterns = patterns(
6+
'',
7+
url(r'^', dispatcher, name='beanstalk_dispatcher'))

beanstalk_dispatch/views.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import json
2+
3+
from base64 import b64decode
4+
from beanstalk_dispatch.execution import execute_function
5+
from jsonview.decorators import json_view
6+
from jsonview.exceptions import BadRequest
7+
from django.views.decorators.csrf import csrf_exempt
8+
from django.views.decorators.http import require_http_methods
9+
10+
import logging
11+
logger = logging.getLogger(__name__)
12+
13+
14+
@require_http_methods(['POST'])
15+
@json_view
16+
@csrf_exempt
17+
def dispatcher(request):
18+
function_request = json.loads(b64decode(request.body.decode()).decode())
19+
try:
20+
execute_function(function_request)
21+
return {}
22+
except Exception as e:
23+
logger.error('Failure running function', exc_info=True)
24+
raise BadRequest(e)

orchestra/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)