Skip to content

Commit 53af7ff

Browse files
committed
works!
1 parent 68e278a commit 53af7ff

31 files changed

+818
-1
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# zappa
2+
zappa_settings.json
13
# Byte-compiled / optimized / DLL files
24
__pycache__/
35
*.py[cod]
@@ -23,6 +25,7 @@ var/
2325
*.egg-info/
2426
.installed.cfg
2527
*.egg
28+
*.pyc
2629

2730
# PyInstaller
2831
# Usually these files are written by a python script from a template

README.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,79 @@
11
# sql-lambda
2-
Register encrypted backends with JOSE and dynamodb and execute sql with lambda
2+
Register backends with AWS Dynamodb and execute sql queries with API Gateway,
3+
which talks to Lambda.
4+
5+
## Usage
6+
7+
Once you've obtained an API_KEY from your administrator or deploying the api, I recommend using [httpie](httpie.org) or [requests](http://docs.python-requests.org/en/master/) in [jupyter notebook](https://github.com/jupyter/notebook) to call the endpoints.
8+
9+
#### http commands in the terminal
10+
```bash
11+
# To list all routes this service contains
12+
http https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/sql x-api-key:$API_KEY
13+
```
14+
15+
#### requests library in python
16+
17+
```python
18+
from os import environ as env
19+
20+
import requests
21+
22+
url = 'https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/sql'
23+
headers = {'x-api-key': env['API_KEY']}
24+
response = requests.get(url, headers=headers)
25+
print(response.json)
26+
27+
```
28+
29+
## Prerequisites for deployment
30+
31+
* AWS profile name in ~/.aws/credentials and valid credentials for deploying to Zappa.
32+
* Update zappa_settings-example.json for your environments
33+
* You will need an API Gateway key for encryption/decryption.
34+
* You will need a service account or user credentials in order to connect.
35+
* Obviously do not share your api key with anyone as they can access whatever you can access with it.
36+
37+
I **STRONGLY** advise you to use `api_key_required: true` as well as [IP restriction in IAM](http://benfoster.io/blog/aws-api-gateway-ip-restrictions) to limit usage of this API to within your VPN.
38+
39+
## Deploying the api
40+
Assuming you're already familiar with zappa deployments here.
41+
42+
You will need to build and run these commands in the provided Dockerfile outside of Linux.
43+
44+
Docker based install instructions [here](https://github.com/danielwhatmuff/zappa#using-exported-aws_default_region-aws_secret_access_key-and-aws_access_key_id-env-vars)
45+
46+
Linux
47+
```
48+
cd ./sql/api
49+
virtualenv venv
50+
. ./venv/bin/activate
51+
pip install -r requirements.txt
52+
zappa deploy dev
53+
```
54+
55+
## Deploying the registration app
56+
57+
58+
Please note to use a separate virtualenv for the api.
59+
```bash
60+
deactivate # We are using a separate virtualenv as these reqs/dockerfile are heavier.
61+
cd ./sql/app
62+
# same as above in a separate virtualenv
63+
```
64+
65+
66+
## First Step for DBA: Register your backend(s)
67+
68+
1. Go to your deployed app URL that zappa provides
69+
2. Click Register backend
70+
3. Follow the instructions and use the api key you got from zappa when deploying the api.
71+
72+
You should see a new table in DynamoDB with your encrypted credentials.
73+
74+
## Test your connection
75+
76+
```bash
77+
http <your-api>/<your-env>/sql/view/<your-registered-backend-name> x-api-key:$API_KEY \
78+
sql=="select 1"
79+
```

api/Dockerfile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
FROM lambci/lambda:build
2+
3+
MAINTAINER "rich fernandez" <richardfernandeznyc@gmail.com>
4+
5+
6+
COPY docker/yum.conf /etc/yum.conf
7+
COPY docker/bashrc /root/.bashrc
8+
9+
RUN yum clean all && \
10+
yum -y install python27-devel python27-pip gcc \
11+
vim \
12+
# Your specific system requirements below
13+
postgresql postgresql-devel freetds-dev
14+
15+
WORKDIR /var/task
16+
COPY . /var/task
17+
18+
RUN pip install -U pip
19+
RUN pip install -U virtualenv
20+
21+
RUN virtualenv venv && \
22+
. ./venv/bin/activate && \
23+
pip install -U pip && \
24+
pip install -U zappa && \
25+
pip install -r requirements.txt
26+
# pip install pandas sqlalchemy

api/api.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#! /usr/bin/env python2
2+
"""SQL as a Service"""
3+
from ast import literal_eval
4+
from functools import partial
5+
from json import loads
6+
from os import environ as env
7+
from traceback import format_exc
8+
9+
from flask import Flask, request, jsonify
10+
from sqlalchemy.exc import ProgrammingError, OperationalError
11+
from pynamodb.exceptions import (
12+
PynamoDBConnectionError,
13+
QueryError,
14+
GetError,
15+
DeleteError,
16+
DoesNotExist
17+
)
18+
19+
from engine import SqlClient
20+
from jinja2 import Environment
21+
from models import Backend
22+
from middleware import list_routes
23+
from utils import decrypt
24+
25+
api = Flask(__name__)
26+
27+
def raise_if_not_exists(model):
28+
if not model.exists():
29+
raise DoesNotExist()
30+
31+
@api.errorhandler(Exception)
32+
def exception_handler(error):
33+
# type: (Exception) -> Exception
34+
"""Show uncaught exceptions.
35+
36+
Args:
37+
error
38+
39+
Raises:
40+
Exception
41+
"""
42+
raise Exception(format_exc())
43+
44+
@api.route('/')
45+
def list_api_routes():
46+
"""List all endpoints"""
47+
return jsonify(list_routes(api))
48+
49+
@api.route('/backend/list')
50+
def get_backends():
51+
raise_if_not_exists(Backend)
52+
return ''.join({item.name:
53+
item.description
54+
for item in Backend.scan()})
55+
56+
@api.route('/register/<backend>', methods=['POST'])
57+
def register_backend(backend):
58+
"""See client.py to generate new backends."""
59+
60+
if not Backend.exists():
61+
print('Creating table Backend for item: %s' % backend)
62+
Backend.create_table(wait=True)
63+
64+
json = request.json
65+
credentials = json['credentials']
66+
description = json['description']
67+
68+
new_backend = Backend(
69+
name=backend,
70+
description=description,
71+
credentials=credentials,
72+
)
73+
new_backend.save()
74+
return jsonify("""{backend}: {description} has been registered.
75+
Run ./scripts/sqlcmd to run SQL
76+
""".format(backend=backend, description=description)
77+
)
78+
79+
80+
@api.route('/backend/<backend>', methods=['DELETE'])
81+
def delete_backend(backend):
82+
raise_if_not_exists(Backend)
83+
items = Backend.query('name', name__eq=backend)
84+
for item in items:
85+
try:
86+
item.delete()
87+
except DeleteError as err:
88+
return err
89+
return jsonify("%s has been deleted" % backend)
90+
91+
92+
@api.route('/view/<backend>')
93+
def view_sql(backend):
94+
"""View data from database.
95+
96+
By default, thise route runs in autocommit false.
97+
98+
Args:
99+
sql: Valid sql for your backend
100+
sql_params: Optional jinja2 params
101+
102+
Returns:
103+
json
104+
"""
105+
key = request.headers['x-api-key']
106+
args = request.args
107+
sql = args['sql']
108+
sql_params = args.get('sql_params', '{}')
109+
rendered_sql = Environment().from_string(
110+
sql).render(dict(**literal_eval(sql_params)))
111+
raise_if_not_exists(Backend)
112+
backend_results = {item.name: item.credentials
113+
for item in Backend.query('name', name__eq=backend)}
114+
credentials = backend_results[backend]
115+
116+
backend_client = SqlClient(decrypt(credentials, key=key), autocommit=False)
117+
viewer = backend_client.sql_viewer()
118+
try:
119+
results = viewer(rendered_sql)
120+
except ProgrammingError as err:
121+
return str(err), 400
122+
except OperationalError as err:
123+
return str(err), 400
124+
except Exception as err:
125+
return str(err), 400
126+
return jsonify(results.to_json(orient='records'))
127+
128+
129+
@api.route('/execute/<backend>', methods=['POST'])
130+
def execute_sql(backend):
131+
"""Modify data from the database.
132+
133+
Args:
134+
sql: valid sql for your backend
135+
sql_params: Optional jinja2 params
136+
137+
Returns:
138+
json
139+
"""
140+
key = request.headers['x-api-key']
141+
args = request.json
142+
sql = args['sql']
143+
sql_params = args.get('sql_params', '{}')
144+
rendered_sql = Environment().from_string(
145+
sql).render(dict(**literal_eval(sql_params)))
146+
autocommit = args.get('autocommit', False)
147+
raise_if_not_exists(Backend)
148+
backend_results = {item.name: item.credentials
149+
for item in Backend.query('name', name__eq=backend)}
150+
credentials = backend_results[backend]
151+
backend_client = SqlClient(decrypt(credentials, key=key), autocommit)
152+
doer = backend_client.sql_doer()
153+
try:
154+
results = doer(rendered_sql)
155+
except OperationalError as err:
156+
return str(err), 400
157+
except ProgrammingError as err:
158+
return str(err), 400
159+
except Exception as err:
160+
return str(err), 400
161+
return jsonify(str("%s row(s) affected." % results.rowcount))
162+
163+
164+
if __name__ == '__main__':
165+
DEBUG = False if env['STAGE'] == 'prod' else True
166+
api.run(debug=DEBUG, port=5001)

api/docker/bashrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
GREEN="\[$(tput setaf 2)\]"
2+
RESET="\[$(tput sgr0)\]"
3+
export PS1="${GREEN}zappa${RESET}> "

api/docker/yum.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[main]
2+
keepcache=0
3+
releasever=2015.09

api/engine.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Engine Builder."""
2+
from sqlalchemy import create_engine
3+
4+
def _create_engine(engine, autocommit=True):
5+
"""Return an engine from standard format."""
6+
base_con_str = (
7+
'{dialect}+{driver}://{username}:{password}@{host}'
8+
':{port}/{database}').format(**engine)
9+
10+
return create_engine(
11+
base_con_str,
12+
execution_options=dict(autocommit=autocommit))
13+
14+
15+
class SqlClient(object):
16+
"""SQL Client for any engine in ENGINES.
17+
18+
>>> redshift = SqlClient('REDSHIFT')
19+
>>> viewer = redshift.sql_viewer()
20+
>>> viewer('select * from table;')
21+
__results__ # as pd.DataFrame
22+
>>> doer = redshift.sql_doer()
23+
>>> doer('insert into table things;')
24+
1 row affected
25+
"""
26+
27+
def __init__(self, engine, autocommit=True):
28+
"""Just need engine from ENGINES object."""
29+
self.engine = _create_engine(engine, autocommit)
30+
31+
def sql_viewer(self):
32+
"""Convenience function to view query results."""
33+
return self.engine.execute
34+
35+
def sql_doer(self):
36+
"""Convenience function to execute table mutations."""
37+
return self.engine.execute

api/middleware.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#! /usr/bin/env python2
2+
import urllib
3+
4+
from flask import url_for
5+
6+
def list_routes(app):
7+
output = []
8+
for rule in app.url_map.iter_rules():
9+
10+
if rule.endpoint == 'static':
11+
continue
12+
13+
options = {}
14+
for arg in rule.arguments:
15+
options[arg] = "[{0}]".format(arg)
16+
17+
methods = ','.join(rule for rule in rule.methods if rule not in ('OPTIONS','HEAD'))
18+
url = url_for(rule.endpoint, **options)
19+
line = urllib.unquote("{:7s}{:40s}{}".format(methods, url, rule.endpoint))
20+
output.append(line)
21+
22+
return [line for line in sorted(output)]
23+
24+
25+

api/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Pynamodb is similar to most ORMs in nature"""
2+
import boto3
3+
from pynamodb.models import Model
4+
from pynamodb.attributes import (
5+
UnicodeAttribute, BooleanAttribute)
6+
7+
class Backend(Model):
8+
class Meta:
9+
# STAGE env always available in Zappa
10+
table_name = 'Backend'
11+
region = boto3.Session().region_name
12+
read_capacity_units = 1
13+
write_capacity_units = 1
14+
15+
name = UnicodeAttribute(hash_key=True)
16+
description = UnicodeAttribute()
17+
credentials = UnicodeAttribute()

api/requirements.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
zappa
2+
flask
3+
python-jose
4+
pynamodb
5+
sqlalchemy
6+
# your database dependencies here
7+
redshift-sqlalchemy
8+
psycopg2
9+
pymssql

0 commit comments

Comments
 (0)