Adding in api server
* API now executes (not fully working but a good start) * Load balancers list now works * Load balancer detail now works * Fix pep8, pyflakes + Shrews' comments * Temporarily make test cases pass Change-Id: Ia082b8bb60a95abee086073908256649e4ebca23
This commit is contained in:
parent
12f2e4bb37
commit
a4e583f1c6
71
etc/libra_api.py
Normal file
71
etc/libra_api.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Server Specific Configurations
|
||||||
|
server = {
|
||||||
|
'port': '8080',
|
||||||
|
'host': '0.0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pecan Application Configurations
|
||||||
|
app = {
|
||||||
|
'root': 'libra.api.controllers.root.RootController',
|
||||||
|
'modules': ['libra.api'],
|
||||||
|
'static_root': '%(confdir)s/public',
|
||||||
|
'template_path': '%(confdir)s/api/templates',
|
||||||
|
'debug': True,
|
||||||
|
'errors': {
|
||||||
|
404: '/error/404',
|
||||||
|
'__force_dict__': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging = {
|
||||||
|
'loggers': {
|
||||||
|
'root': {'level': 'INFO', 'handlers': ['console']},
|
||||||
|
'api': {'level': 'DEBUG', 'handlers': ['console']}
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'simple'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'formatters': {
|
||||||
|
'simple': {
|
||||||
|
'format': ('%(asctime)s %(levelname)-5.5s [%(name)s]'
|
||||||
|
'[%(threadName)s] %(message)s')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database = {
|
||||||
|
'username': 'root',
|
||||||
|
'password': 'testaburger',
|
||||||
|
'host': 'localhost',
|
||||||
|
'schema': 'lbaas'
|
||||||
|
}
|
||||||
|
|
||||||
|
gearman = {
|
||||||
|
'server': ['localhost:4730'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom Configurations must be in Python dictionary format::
|
||||||
|
#
|
||||||
|
# foo = {'bar':'baz'}
|
||||||
|
#
|
||||||
|
# All configurations are accessible at::
|
||||||
|
# pecan.conf
|
34
libra/api/app.py
Normal file
34
libra/api/app.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from pecan import make_app
|
||||||
|
from libra.api import model
|
||||||
|
|
||||||
|
|
||||||
|
def setup_app(config):
|
||||||
|
|
||||||
|
model.init_model()
|
||||||
|
|
||||||
|
return make_app(
|
||||||
|
config.app.root,
|
||||||
|
static_root=config.app.static_root,
|
||||||
|
template_path=config.app.template_path,
|
||||||
|
logging=getattr(config, 'logging', {}),
|
||||||
|
debug=getattr(config.app, 'debug', False),
|
||||||
|
force_canonical=getattr(config.app, 'force_canonical', True),
|
||||||
|
guess_content_type_from_ext=getattr(
|
||||||
|
config.app,
|
||||||
|
'guess_content_type_from_ext',
|
||||||
|
True),
|
||||||
|
)
|
13
libra/api/controllers/__init__.py
Normal file
13
libra/api/controllers/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
66
libra/api/controllers/connection_throttle.py
Normal file
66
libra/api/controllers/connection_throttle.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from pecan import expose, response
|
||||||
|
from pecan.rest import RestController
|
||||||
|
|
||||||
|
from libra.api.model.responses import Responses
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionThrottleController(RestController):
|
||||||
|
"""functions for /loadbalancers/{loadBalancerId}/connectionthrottle/*
|
||||||
|
routing"""
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def get(self, load_balancer_id):
|
||||||
|
"""List connection throttling configuration.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers/{load_balancer_id}/connectionthrottle
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.ConnectionThrottle.get
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def post(self, load_balancer_id, *args):
|
||||||
|
"""Update throttling configuration.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
:param *args: holds the posted json or xml
|
||||||
|
|
||||||
|
Url:
|
||||||
|
PUT /loadbalancers/loadBalancerId/connectionthrottle
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.ConnectionThrottle.get
|
||||||
|
|
||||||
|
@expose()
|
||||||
|
def delete(self, loadbalancer_id):
|
||||||
|
"""Remove connection throttling configurations.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
DELETE /loadbalancers/loadBalancerId/connectionthrottle
|
||||||
|
|
||||||
|
Returns: void
|
||||||
|
"""
|
||||||
|
response.status = 201
|
65
libra/api/controllers/health_monitor.py
Normal file
65
libra/api/controllers/health_monitor.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from pecan import expose, response
|
||||||
|
from pecan.rest import RestController
|
||||||
|
|
||||||
|
from libra.api.model.responses import Responses
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMonitorController(RestController):
|
||||||
|
"""functions for /loadbalancers/{loadBalancerId}/healthmonitor/* routing"""
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def get(self, load_balancer_id):
|
||||||
|
"""Retrieve the health monitor configuration, if one exists.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers/{load_balancer_id}/healthmonitor
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.get
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def post(self, load_balancer_id, *args):
|
||||||
|
"""Update the settings for a health monitor.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
:param *args: holds the posted json or xml data
|
||||||
|
|
||||||
|
Url:
|
||||||
|
PUT /loadbalancers/{load_balancer_id}/healthmonitor
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.get
|
||||||
|
|
||||||
|
@expose()
|
||||||
|
def delete(self, load_balancer_id):
|
||||||
|
"""Remove the health monitor.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
DELETE /loadbalancers/{load_balancer_id}/healthmonitor
|
||||||
|
|
||||||
|
Returns: void
|
||||||
|
"""
|
||||||
|
response.status = 201
|
313
libra/api/controllers/load_balancers.py
Normal file
313
libra/api/controllers/load_balancers.py
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
#import gearman.errors
|
||||||
|
|
||||||
|
import json
|
||||||
|
#import socket
|
||||||
|
#import time
|
||||||
|
# pecan imports
|
||||||
|
from pecan import expose, abort, response
|
||||||
|
from pecan.rest import RestController
|
||||||
|
# other controllers
|
||||||
|
from nodes import NodesController
|
||||||
|
from health_monitor import HealthMonitorController
|
||||||
|
from session_persistence import SessionPersistenceController
|
||||||
|
from connection_throttle import ConnectionThrottleController
|
||||||
|
#from sqlalchemy.orm import aliased
|
||||||
|
# default response objects
|
||||||
|
from libra.api.model.responses import Responses
|
||||||
|
# models
|
||||||
|
from libra.api.model.lbaas import LoadBalancer, Device, Node, session
|
||||||
|
from libra.api.library.gearman_client import gearman_client
|
||||||
|
|
||||||
|
|
||||||
|
class LoadBalancersController(RestController):
|
||||||
|
"""functions for /loadbalancer routing"""
|
||||||
|
loadbalancer_status = (
|
||||||
|
'ACTIVE',
|
||||||
|
'BUILD',
|
||||||
|
'PENDING_UPDATE',
|
||||||
|
'PENDING_DELETE',
|
||||||
|
'DELETED',
|
||||||
|
'SUSPENDED',
|
||||||
|
'ERROR'
|
||||||
|
)
|
||||||
|
|
||||||
|
"""nodes subclass linking
|
||||||
|
controller class for urls that look like
|
||||||
|
/loadbalancers/{loadBalancerId}/nodes/*
|
||||||
|
"""
|
||||||
|
nodes = NodesController()
|
||||||
|
|
||||||
|
"""healthmonitor instance
|
||||||
|
controller class for urls that start with
|
||||||
|
/loadbalancers/{loadBalancerId}/healthmonitor/*
|
||||||
|
"""
|
||||||
|
healthmonitor = HealthMonitorController()
|
||||||
|
|
||||||
|
"""healthmonitor instance
|
||||||
|
controller class for urls that start with
|
||||||
|
/loadbalancers/{loadBalancerId}/sessionpersistence/*
|
||||||
|
"""
|
||||||
|
sessionpersistence = SessionPersistenceController()
|
||||||
|
|
||||||
|
"""connectionthrottle instance
|
||||||
|
controller class for urls that start with
|
||||||
|
/loadbalancers/{loadBalancerId}/connectionthrottle/*
|
||||||
|
"""
|
||||||
|
connectionthrottle = ConnectionThrottleController()
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def get(self, load_balancer_id=None):
|
||||||
|
"""Fetches a list of load balancers or the details of one balancer if
|
||||||
|
load_balancer_id is not empty.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb we want to get, if none it returns a
|
||||||
|
list of all
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers
|
||||||
|
List all load balancers configured for the account.
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers/{load_balancer_id}
|
||||||
|
List details of the specified load balancer.
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
# have not implimented the keystone middleware so we don't know the
|
||||||
|
# tenantid
|
||||||
|
tenant_id = 80074562416143
|
||||||
|
|
||||||
|
# if we don't have an id then we want a list of them own by this tenent
|
||||||
|
if not load_balancer_id:
|
||||||
|
#return Responses.LoadBalancers.get
|
||||||
|
load_balancers = {'loadBalancers': session.query(
|
||||||
|
LoadBalancer.name, LoadBalancer.id, LoadBalancer.protocol,
|
||||||
|
LoadBalancer.port, LoadBalancer.algorithm,
|
||||||
|
LoadBalancer.status, LoadBalancer.created,
|
||||||
|
LoadBalancer.updated
|
||||||
|
).filter_by(tenantid=tenant_id).all()}
|
||||||
|
else:
|
||||||
|
#return Responses.LoadBalancers.detail
|
||||||
|
load_balancers = session.query(
|
||||||
|
LoadBalancer.name, LoadBalancer.id, LoadBalancer.protocol,
|
||||||
|
LoadBalancer.port, LoadBalancer.algorithm,
|
||||||
|
LoadBalancer.status, LoadBalancer.created,
|
||||||
|
LoadBalancer.updated
|
||||||
|
).join(LoadBalancer.devices).\
|
||||||
|
filter(LoadBalancer.tenantid == tenant_id).\
|
||||||
|
filter(LoadBalancer.id == load_balancer_id).\
|
||||||
|
first()._asdict()
|
||||||
|
|
||||||
|
virtualIps = session.query(
|
||||||
|
Device.id, Device.floatingIpAddr
|
||||||
|
).join(LoadBalancer.devices).\
|
||||||
|
filter(LoadBalancer.tenantid == tenant_id).\
|
||||||
|
filter(LoadBalancer.id == load_balancer_id).\
|
||||||
|
all()
|
||||||
|
|
||||||
|
load_balancers['virtualIps'] = []
|
||||||
|
for item in virtualIps:
|
||||||
|
vip = item._asdict()
|
||||||
|
vip['type'] = 'PUBLIC'
|
||||||
|
vip['ipVersion'] = 'IPV4'
|
||||||
|
load_balancers['virtualIps'].append(vip)
|
||||||
|
|
||||||
|
nodes = session.query(
|
||||||
|
Node.id, Node.address, Node.port, Node.status, Node.enabled
|
||||||
|
).join(LoadBalancer.nodes).\
|
||||||
|
filter(LoadBalancer.tenantid == tenant_id).\
|
||||||
|
filter(LoadBalancer.id == load_balancer_id).\
|
||||||
|
all()
|
||||||
|
|
||||||
|
load_balancers['nodes'] = []
|
||||||
|
for item in nodes:
|
||||||
|
node = item._asdict()
|
||||||
|
if node['enabled'] == 1:
|
||||||
|
node['condition'] = 'ENABLED'
|
||||||
|
else:
|
||||||
|
node['condition'] = 'DISABLED'
|
||||||
|
del node['enabled']
|
||||||
|
load_balancers['nodes'].append(node)
|
||||||
|
|
||||||
|
if load_balancers is None:
|
||||||
|
return Responses.not_found
|
||||||
|
else:
|
||||||
|
return load_balancers
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def post(self, load_balancer_id=None, **kwargs):
|
||||||
|
"""Accepts edit if load_balancer_id isn't blank or create load balancer
|
||||||
|
posts.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
:param *args: holds the posted json or xml data
|
||||||
|
|
||||||
|
Urls:
|
||||||
|
POST /loadbalancers/{load_balancer_id}
|
||||||
|
PUT /loadbalancers
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
curl -i -H "Accept: application/json" -X POST \
|
||||||
|
-d "data={"name": "my_lb"}" \
|
||||||
|
http://dev.server:8080/loadbalancers/100
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
# have not implimented the keystone middleware so we don't know the
|
||||||
|
# tenantid
|
||||||
|
tenant_id = 80074562416143
|
||||||
|
|
||||||
|
# load input
|
||||||
|
data = json.loads(kwargs['data'])
|
||||||
|
# TODO validate input data
|
||||||
|
|
||||||
|
# if we don't have an id then we want to create a new lb
|
||||||
|
if not load_balancer_id:
|
||||||
|
lb = LoadBalancer()
|
||||||
|
|
||||||
|
# find free device
|
||||||
|
device = Device.find_free_device()
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
response.status = 503
|
||||||
|
return Responses.service_unavailable
|
||||||
|
|
||||||
|
lb.device = device.id
|
||||||
|
lb.tenantid = tenant_id
|
||||||
|
|
||||||
|
lb.update_from_json(data)
|
||||||
|
|
||||||
|
# write to database
|
||||||
|
session.add(lb)
|
||||||
|
session.flush()
|
||||||
|
#refresh the lb record so we get the id back
|
||||||
|
session.refresh(lb)
|
||||||
|
|
||||||
|
# now save the loadbalancer_id to the device and switch its status
|
||||||
|
# to online
|
||||||
|
device.loadbalancers = lb.id
|
||||||
|
device.status = "ONLINE"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# grab the lb
|
||||||
|
lb = session.query(LoadBalancer)\
|
||||||
|
.filter_by(id=load_balancer_id).first()
|
||||||
|
|
||||||
|
if lb is None:
|
||||||
|
response.status = 400
|
||||||
|
return Responses.not_found
|
||||||
|
|
||||||
|
lb.update_from_json(data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# trigger gearman client to create new lb
|
||||||
|
result = gearman_client.submit_job('UPDATE', lb.output_to_json())
|
||||||
|
|
||||||
|
# do something with result
|
||||||
|
if result:
|
||||||
|
pass
|
||||||
|
|
||||||
|
response.status = 200
|
||||||
|
return self.get()
|
||||||
|
except:
|
||||||
|
response.status = 503
|
||||||
|
return Responses.service_unavailable
|
||||||
|
|
||||||
|
@expose()
|
||||||
|
def delete(self, load_balancer_id):
|
||||||
|
"""Remove a load balancer from the account.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Urls:
|
||||||
|
DELETE /loadbalancers/{load_balancer_id}
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
curl -i -H "Accept: application/json" -X DELETE
|
||||||
|
http://dev.server:8080/loadbalancers/1
|
||||||
|
|
||||||
|
Returns: None
|
||||||
|
"""
|
||||||
|
# grab the lb
|
||||||
|
lb = session.query(LoadBalancer)\
|
||||||
|
.filter_by(id=load_balancer_id).first()
|
||||||
|
|
||||||
|
if lb is None:
|
||||||
|
response.status = 400
|
||||||
|
return Responses.not_found
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# trigger gearman client to create new lb
|
||||||
|
result = gearman_client.submit_job('DELETE', lb.output_to_json())
|
||||||
|
|
||||||
|
if result:
|
||||||
|
pass
|
||||||
|
|
||||||
|
response.status = 200
|
||||||
|
|
||||||
|
session.delete(lb)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return self.get()
|
||||||
|
except:
|
||||||
|
response.status = 503
|
||||||
|
return Responses.service_unavailable
|
||||||
|
|
||||||
|
def virtualips(self, load_balancer_id):
|
||||||
|
"""Returns a list of virtual ips attached to a specific Load Balancer.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers/{load_balancer_id}/virtualips
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
return Responses.LoadBalancers.virtualips
|
||||||
|
|
||||||
|
def usage(self, load_balancer_id):
|
||||||
|
"""List current and historical usage.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers/{load_balancer_id}/usage
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.usage
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def _lookup(self, primary_key, *remainder):
|
||||||
|
"""Routes more complex url mapping.
|
||||||
|
|
||||||
|
:param primary_key: value to look up or pass
|
||||||
|
:param *remainder: remaining args
|
||||||
|
|
||||||
|
Raises: 404
|
||||||
|
"""
|
||||||
|
#student = get_student_by_primary_key(primary_key)
|
||||||
|
#if student:
|
||||||
|
# return StudentController(student), remainder
|
||||||
|
#else:
|
||||||
|
abort(404)
|
74
libra/api/controllers/nodes.py
Normal file
74
libra/api/controllers/nodes.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from pecan import expose, response
|
||||||
|
from pecan.rest import RestController
|
||||||
|
#default response objects
|
||||||
|
#from libra.api.model.lbaas import Device, LoadBalancer, Node, session
|
||||||
|
from libra.api.model.responses import Responses
|
||||||
|
|
||||||
|
|
||||||
|
class NodesController(RestController):
|
||||||
|
"""Functions for /loadbalancers/{load_balancer_id}/nodes/* routing"""
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def get(self, load_balancer_id, node_id=None):
|
||||||
|
"""List node(s) configured for the load balancer OR if
|
||||||
|
node_id == None .. Retrieve the configuration of node {node_id} of
|
||||||
|
loadbalancer {load_balancer_id}.
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
:param node_id: id of node (optional)
|
||||||
|
|
||||||
|
Urls:
|
||||||
|
GET /loadbalancers/{load_balancer_id}/nodes
|
||||||
|
GET /loadbalancers/{load_balancer_id}/nodes/{node_id}
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.Nodes.get
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def post(self, load_balancer_id, node_id=None, *args):
|
||||||
|
"""Adds a new node to the load balancer OR Modify the configuration
|
||||||
|
of a node on the load balancer.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
:param node_id: id of node (optional) when missing a new node is added.
|
||||||
|
:param *args: holds the posted json or xml data
|
||||||
|
|
||||||
|
Urls:
|
||||||
|
POST /loadbalancers/{load_balancer_id}/nodes
|
||||||
|
PUT /loadbalancers/{load_balancer_id}/nodes/{node_id}
|
||||||
|
|
||||||
|
Returns: dict of the full list of nodes or the details of the single
|
||||||
|
node
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.Nodes.get
|
||||||
|
|
||||||
|
@expose()
|
||||||
|
def delete(self, load_balancer_id, node_id):
|
||||||
|
"""Remove a node from the load balancer.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
:param node_id: id of node
|
||||||
|
|
||||||
|
Url:
|
||||||
|
DELETE /loadbalancers/{load_balancer_id}/nodes/{node_id}
|
||||||
|
|
||||||
|
Returns: None
|
||||||
|
"""
|
||||||
|
response.status = 201
|
56
libra/api/controllers/root.py
Normal file
56
libra/api/controllers/root.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from pecan import expose, response
|
||||||
|
from load_balancers import LoadBalancersController
|
||||||
|
from libra.api.model.responses import Responses
|
||||||
|
|
||||||
|
|
||||||
|
class RootController(object):
|
||||||
|
"""root control object."""
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def _default(self):
|
||||||
|
"""default route.. acts as catch all for any wrong urls.
|
||||||
|
For now it returns a 404 because no action is defined for /"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses._default
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def protocols(self):
|
||||||
|
"""Lists all supported load balancing protocols.
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /protocols
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.protocols
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def algorithms(self):
|
||||||
|
"""List all supported load balancing algorithms.
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /algorithms
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.algorithms
|
||||||
|
|
||||||
|
#pecan uses this controller class for urls that start with /loadbalancers
|
||||||
|
loadbalancers = LoadBalancersController()
|
65
libra/api/controllers/session_persistence.py
Normal file
65
libra/api/controllers/session_persistence.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from pecan import expose, response
|
||||||
|
from pecan.rest import RestController
|
||||||
|
from libra.api.model.responses import Responses
|
||||||
|
|
||||||
|
|
||||||
|
class SessionPersistenceController(RestController):
|
||||||
|
"""SessionPersistenceController
|
||||||
|
functions for /loadbalancers/{loadBalancerId}/sessionpersistence/* routing
|
||||||
|
"""
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def get(self, load_balancer_id):
|
||||||
|
"""List session persistence configuration.get
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
GET /loadbalancers/{load_balancer_id}/sessionpersistence
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.SessionPersistence.get
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def post(self, load_balancer_id):
|
||||||
|
"""Enable session persistence.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
PUT /loadbalancers/{load_balancer_id}/sessionpersistence
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
||||||
|
return Responses.LoadBalancers.SessionPersistence.get
|
||||||
|
|
||||||
|
@expose('json')
|
||||||
|
def delete(self, load_balancer_id):
|
||||||
|
"""Disable session persistence.
|
||||||
|
|
||||||
|
:param load_balancer_id: id of lb
|
||||||
|
|
||||||
|
Url:
|
||||||
|
DELETE /loadbalancers/{load_balancer_id}/sessionpersistence
|
||||||
|
|
||||||
|
Returns: dict
|
||||||
|
"""
|
||||||
|
response.status = 201
|
0
libra/api/library/__init__.py
Normal file
0
libra/api/library/__init__.py
Normal file
29
libra/api/library/gearman_client.py
Normal file
29
libra/api/library/gearman_client.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from libra.common.json_gearman import JSONGearmanClient
|
||||||
|
from pecan import conf
|
||||||
|
|
||||||
|
|
||||||
|
gearman_client = JSONGearmanClient(conf.gearman.server)
|
||||||
|
|
||||||
|
gearman_workers = [
|
||||||
|
'UPDATE', # Create/Update a Load Balancer.
|
||||||
|
'SUSPEND', # Suspend a Load Balancer.
|
||||||
|
'ENABLE', # Enable a suspended Load Balancer.
|
||||||
|
'DELETE', # Delete a Load Balancer.
|
||||||
|
'DISCOVER', # Return service discovery information.
|
||||||
|
'ARCHIVE', # Archive LB log files.
|
||||||
|
'STATS' # Get load balancer statistics.
|
||||||
|
]
|
@ -1,78 +0,0 @@
|
|||||||
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
||||||
# not use this file except in compliance with the License. You may obtain
|
|
||||||
# a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
||||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
||||||
# License for the specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
|
|
||||||
import daemon
|
|
||||||
import daemon.pidfile
|
|
||||||
import daemon.runner
|
|
||||||
import grp
|
|
||||||
import lockfile
|
|
||||||
import os
|
|
||||||
import pwd
|
|
||||||
|
|
||||||
from libra.common.options import Options, setup_logging
|
|
||||||
|
|
||||||
|
|
||||||
class APIServer(object):
|
|
||||||
def __init__(self, logger, args):
|
|
||||||
self.logger = logger
|
|
||||||
self.args = args
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
options = Options('api', 'API Daemon')
|
|
||||||
args = options.run()
|
|
||||||
|
|
||||||
logger = setup_logging('api_server', args)
|
|
||||||
server = APIServer(logger, args)
|
|
||||||
|
|
||||||
if args.nodaemon:
|
|
||||||
server.main()
|
|
||||||
else:
|
|
||||||
pidfile = daemon.pidfile.TimeoutPIDLockFile(args.pid, 10)
|
|
||||||
if daemon.runner.is_pidfile_stale(pidfile):
|
|
||||||
logger.warning("Cleaning up stale PID file")
|
|
||||||
pidfile.break_lock()
|
|
||||||
context = daemon.DaemonContext(
|
|
||||||
working_directory='/etc/libra',
|
|
||||||
umask=0o022,
|
|
||||||
pidfile=pidfile,
|
|
||||||
files_preserve=[logger.handlers[0].stream]
|
|
||||||
)
|
|
||||||
if args.user:
|
|
||||||
try:
|
|
||||||
context.uid = pwd.getpwnam(args.user).pw_uid
|
|
||||||
except KeyError:
|
|
||||||
logger.critical("Invalid user: %s" % args.user)
|
|
||||||
return 1
|
|
||||||
# NOTE(LinuxJedi): we are switching user so need to switch
|
|
||||||
# the ownership of the log file for rotation
|
|
||||||
os.chown(logger.handlers[0].baseFilename, context.uid, -1)
|
|
||||||
if args.group:
|
|
||||||
try:
|
|
||||||
context.gid = grp.getgrnam(args.group).gr_gid
|
|
||||||
except KeyError:
|
|
||||||
logger.critical("Invalid group: %s" % args.group)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
context.open()
|
|
||||||
except lockfile.LockTimeout:
|
|
||||||
logger.critical(
|
|
||||||
"Failed to lock pidfile %s, another instance running?",
|
|
||||||
args.pid
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
server.main()
|
|
||||||
return 0
|
|
27
libra/api/model/__init__.py
Normal file
27
libra/api/model/__init__.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
|
||||||
|
def init_model():
|
||||||
|
"""
|
||||||
|
This is a stub method which is called at application startup time.
|
||||||
|
|
||||||
|
If you need to bind to a parse database configuration, set up tables or
|
||||||
|
ORM classes, or perform any database initialization, this is the
|
||||||
|
recommended place to do it.
|
||||||
|
|
||||||
|
For more information working with databases, and some common recipes,
|
||||||
|
see http://pecan.readthedocs.org/en/latest/databases.html
|
||||||
|
"""
|
||||||
|
pass
|
109
libra/api/model/lbaas.py
Normal file
109
libra/api/model/lbaas.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the 'License'); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from sqlalchemy import Table, Column, Integer, ForeignKey, create_engine
|
||||||
|
from sqlalchemy import INTEGER, VARCHAR, TIMESTAMP, BIGINT
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship, backref, sessionmaker
|
||||||
|
from pecan import conf
|
||||||
|
|
||||||
|
# TODO replace this with something better
|
||||||
|
conn_string = '''mysql://%s:%s@%s/%s''' % (
|
||||||
|
conf.database.username,
|
||||||
|
conf.database.password,
|
||||||
|
conf.database.host,
|
||||||
|
conf.database.schema
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(conn_string)
|
||||||
|
DeclarativeBase = declarative_base()
|
||||||
|
metadata = DeclarativeBase.metadata
|
||||||
|
metadata.bind = engine
|
||||||
|
|
||||||
|
loadbalancers_devices = Table(
|
||||||
|
'loadbalancers_devices',
|
||||||
|
metadata,
|
||||||
|
Column('loadbalancer', Integer, ForeignKey('loadbalancers.id')),
|
||||||
|
Column('device', Integer, ForeignKey('devices.id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Device(DeclarativeBase):
|
||||||
|
"""device model"""
|
||||||
|
__tablename__ = 'devices'
|
||||||
|
#column definitions
|
||||||
|
az = Column(u'az', INTEGER(), nullable=False)
|
||||||
|
created = Column(u'created', TIMESTAMP(), nullable=False)
|
||||||
|
floatingIpAddr = Column(
|
||||||
|
u'floatingIpAddr', VARCHAR(length=128), nullable=False
|
||||||
|
)
|
||||||
|
id = Column(u'id', BIGINT(), primary_key=True, nullable=False)
|
||||||
|
name = Column(u'name', VARCHAR(length=128), nullable=False)
|
||||||
|
publicIpAddr = Column(u'publicIpAddr', VARCHAR(length=128), nullable=False)
|
||||||
|
status = Column(u'status', VARCHAR(length=128), nullable=False)
|
||||||
|
type = Column(u'type', VARCHAR(length=128), nullable=False)
|
||||||
|
updated = Column(u'updated', TIMESTAMP(), nullable=False)
|
||||||
|
|
||||||
|
def find_free_device(self):
|
||||||
|
"""queries for free and clear device
|
||||||
|
|
||||||
|
sql form java api
|
||||||
|
SELECT * FROM devices WHERE loadbalancers = " + EMPTY_LBIDS + " AND
|
||||||
|
status = '" + Device.STATUS_OFFLINE + "'" ;
|
||||||
|
"""
|
||||||
|
return session.query(Device).\
|
||||||
|
filter_by(loadbalancers="", status="OFFLINE").\
|
||||||
|
first()
|
||||||
|
|
||||||
|
|
||||||
|
class LoadBalancer(DeclarativeBase):
|
||||||
|
"""load balancer model"""
|
||||||
|
__tablename__ = 'loadbalancers'
|
||||||
|
#column definitions
|
||||||
|
algorithm = Column(u'algorithm', VARCHAR(length=80), nullable=False)
|
||||||
|
created = Column(u'created', TIMESTAMP(), nullable=False)
|
||||||
|
errmsg = Column(u'errmsg', VARCHAR(length=128))
|
||||||
|
id = Column(u'id', BIGINT(), primary_key=True, nullable=False)
|
||||||
|
name = Column(u'name', VARCHAR(length=128), nullable=False)
|
||||||
|
port = Column(u'port', INTEGER(), nullable=False)
|
||||||
|
protocol = Column(u'protocol', VARCHAR(length=128), nullable=False)
|
||||||
|
status = Column(u'status', VARCHAR(length=50), nullable=False)
|
||||||
|
tenantid = Column(u'tenantid', VARCHAR(length=128), nullable=False)
|
||||||
|
updated = Column(u'updated', TIMESTAMP(), nullable=False)
|
||||||
|
nodes = relationship(
|
||||||
|
'Node', backref=backref('loadbalancers', order_by='Node.id')
|
||||||
|
)
|
||||||
|
devices = relationship(
|
||||||
|
'Device', secondary=loadbalancers_devices, backref='loadbalancers',
|
||||||
|
lazy='joined'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Node(DeclarativeBase):
|
||||||
|
"""node model"""
|
||||||
|
__tablename__ = 'nodes'
|
||||||
|
#column definitions
|
||||||
|
address = Column(u'address', VARCHAR(length=128), nullable=False)
|
||||||
|
enabled = Column(u'enabled', Integer(), nullable=False)
|
||||||
|
id = Column(u'id', BIGINT(), primary_key=True, nullable=False)
|
||||||
|
lbid = Column(
|
||||||
|
u'lbid', BIGINT(), ForeignKey('loadbalancers.id'), nullable=False
|
||||||
|
)
|
||||||
|
port = Column(u'port', INTEGER(), nullable=False)
|
||||||
|
status = Column(u'status', VARCHAR(length=128), nullable=False)
|
||||||
|
weight = Column(u'weight', INTEGER(), nullable=False)
|
||||||
|
|
||||||
|
"""session"""
|
||||||
|
session = sessionmaker(bind=engine)()
|
66
libra/api/model/lbaas.sql
Normal file
66
libra/api/model/lbaas.sql
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# LBaaS Database schema
|
||||||
|
# pemellquist@gmail.com
|
||||||
|
|
||||||
|
DROP DATABASE IF EXISTS lbaas;
|
||||||
|
CREATE DATABASE lbaas;
|
||||||
|
USE lbaas;
|
||||||
|
|
||||||
|
# versions, used to define overall version for schema
|
||||||
|
# major version differences are not backward compatibile
|
||||||
|
create TABLE versions (
|
||||||
|
major INT NOT NULL,
|
||||||
|
minor INT NOT NULL,
|
||||||
|
PRIMARY KEY (major)
|
||||||
|
);
|
||||||
|
INSERT INTO versions values (2,0);
|
||||||
|
|
||||||
|
# loadbalancers
|
||||||
|
CREATE TABLE loadbalancers (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, # unique id for this loadbalancer, generated by DB when record is created
|
||||||
|
name VARCHAR(128) NOT NULL, # tenant assigned load balancer name
|
||||||
|
tenantid VARCHAR(128) NOT NULL, # tenant id who owns this loadbalancer
|
||||||
|
protocol VARCHAR(128) NOT NULL, # loadbalancer protocol used, can be 'HTTP', 'TCP' or 'HTTPS'
|
||||||
|
port INT NOT NULL, # TCP port number associated with protocol and used by loadbalancer northbound interface
|
||||||
|
status VARCHAR(50) NOT NULL, # current status, see ATLAS API 1.1 for all possible values
|
||||||
|
algorithm VARCHAR(80) NOT NULL, # LB Algorithm in use e.g. ROUND_ROBIN, see ATLAS API 1.1 for all possible values
|
||||||
|
created TIMESTAMP NOT NULL, # timestamp of when LB was created
|
||||||
|
updated TIMESTAMP NOT NULL, # timestamp of when LB was last updated
|
||||||
|
device BIGINT NOT NULL, # reference to associated device OR '0' for unassigned
|
||||||
|
errmsg VARCHAR(128), # optional error message which can describe details regarding LBs state, can be blank if no error state exists
|
||||||
|
PRIMARY KEY (id) # ids are unique accross all LBs
|
||||||
|
) DEFAULT CHARSET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||||
|
|
||||||
|
#nodes
|
||||||
|
CREATE TABLE nodes (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, # unique id for this node, generated by DB when record is created
|
||||||
|
lbid BIGINT NOT NULL, # Loadbalancer who owns this node
|
||||||
|
address VARCHAR(128) NOT NULL, # IPV4 or IPV6 address for this node
|
||||||
|
port INT NOT NULL, # TCP port number associated with this node and used from LB to node
|
||||||
|
weight INT NOT NULL, # Node weight if applicable to algorithm used
|
||||||
|
enabled BOOLEAN NOT NULL, # is node enabled or not
|
||||||
|
status VARCHAR(128) NOT NULL, # status of node 'OFFLINE', 'ONLINE', 'ERROR', this value is reported by the device
|
||||||
|
PRIMARY KEY (id) # ids are unique accross all Nodes
|
||||||
|
) DEFAULT CHARSET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||||
|
|
||||||
|
|
||||||
|
# devices
|
||||||
|
CREATE TABLE devices (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, # unique id for this device, generated by DB when record is created
|
||||||
|
name VARCHAR(128) NOT NULL, # admin assigned device name, this is the unique gearman worker function name
|
||||||
|
floatingIpAddr VARCHAR(128) NOT NULL, # IPV4 or IPV6 address of device for floating IP
|
||||||
|
publicIpAddr VARCHAR(128) NOT NULL, # IPV4 or IPV6 address of device for floating IP
|
||||||
|
loadbalancers VARCHAR(128) NOT NULL, # Reference to loadbalancers using this device ( JSON array )
|
||||||
|
az INT NOT NULL, # availability zone in which this device exists
|
||||||
|
type VARCHAR(128) NOT NULL, # text description of type of device, e.g. 'HAProxy'
|
||||||
|
created TIMESTAMP NOT NULL, # timestamp of when device was created
|
||||||
|
updated TIMESTAMP NOT NULL, # timestamp of when device was last updated
|
||||||
|
status VARCHAR(128) NOT NULL, # status of device 'OFFLINE', 'ONLINE', 'ERROR', this value is reported by the device
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) DEFAULT CHARSET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `loadbalancers_devices` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`loadbalancer` int(11) DEFAULT NULL,
|
||||||
|
`device` int(11) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=latin1
|
296
libra/api/model/responses.py
Normal file
296
libra/api/model/responses.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the 'License'); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
"""Class Responses
|
||||||
|
responder objects for framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Responses(object):
|
||||||
|
"""404 - not found"""
|
||||||
|
_default = {'status': '404'}
|
||||||
|
|
||||||
|
"""not found """
|
||||||
|
not_found = {'message': 'Object not Found'}
|
||||||
|
|
||||||
|
"""service_unavailable"""
|
||||||
|
service_unavailable = {'message': 'Service Unavailable'}
|
||||||
|
|
||||||
|
"""algorithms response"""
|
||||||
|
algorithms = {
|
||||||
|
'algorithms': [
|
||||||
|
{'name': 'ROUND_ROBIN'},
|
||||||
|
{'name': 'LEAST_CONNECTIONS'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""protocols response"""
|
||||||
|
protocols = {
|
||||||
|
'protocols': [
|
||||||
|
{
|
||||||
|
'name': 'HTTP',
|
||||||
|
'port': '80'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'HTTPS',
|
||||||
|
'port': '443'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'TCP',
|
||||||
|
'port': '*'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""class LoadBalancers
|
||||||
|
grouping of lb responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
class LoadBalancers(object):
|
||||||
|
"""LoadBalancers list"""
|
||||||
|
get = {
|
||||||
|
'loadBalancers': [
|
||||||
|
{
|
||||||
|
'name': 'lb-site1',
|
||||||
|
'id': '71',
|
||||||
|
'protocol': 'HTTP',
|
||||||
|
'port': '80',
|
||||||
|
'algorithm': 'LEAST_CONNECTIONS',
|
||||||
|
'status': 'ACTIVE',
|
||||||
|
'created': '2010-11-30T03:23:42Z',
|
||||||
|
'updated': '2010-11-30T03:23:44Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'lb-site2',
|
||||||
|
'id': '166',
|
||||||
|
'protocol': 'TCP',
|
||||||
|
'port': '9123',
|
||||||
|
'algorithm': 'ROUND_ROBIN',
|
||||||
|
'status': 'ACTIVE',
|
||||||
|
'created': '2010-11-30T03:23:42Z',
|
||||||
|
'updated': '2010-11-30T03:23:44Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""loadbalancer details"""
|
||||||
|
detail = {
|
||||||
|
'id': '2000',
|
||||||
|
'name': 'sample-loadbalancer',
|
||||||
|
'protocol': 'HTTP',
|
||||||
|
'port': '80',
|
||||||
|
'algorithm': 'ROUND_ROBIN',
|
||||||
|
'status': 'ACTIVE',
|
||||||
|
'created': '2010-11-30T03:23:42Z',
|
||||||
|
'updated': '2010-11-30T03:23:44Z',
|
||||||
|
'virtualIps': [
|
||||||
|
{
|
||||||
|
'id': '1000',
|
||||||
|
'address': '2001:cdba:0000:0000:0000:0000:3257:9652',
|
||||||
|
'type': 'PUBLIC',
|
||||||
|
'ipVersion': 'IPV6'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'nodes': [
|
||||||
|
{
|
||||||
|
'id': '1041',
|
||||||
|
'address': '10.1.1.1',
|
||||||
|
'port': '80',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'ONLINE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '1411',
|
||||||
|
'address': '10.1.1.2',
|
||||||
|
'port': '80',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'ONLINE'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'sessionPersistence': {
|
||||||
|
'persistenceType': 'HTTP_COOKIE'
|
||||||
|
},
|
||||||
|
'connectionThrottle': {
|
||||||
|
'maxRequestRate': '50',
|
||||||
|
'rateInterval': '60'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"""create loadbalancer response"""
|
||||||
|
post = {
|
||||||
|
'name': 'a-new-loadbalancer',
|
||||||
|
'id': '144',
|
||||||
|
'protocol': 'HTTP',
|
||||||
|
'port': '83',
|
||||||
|
'algorithm': 'ROUND_ROBIN',
|
||||||
|
'status': 'BUILD',
|
||||||
|
'created': '2011-04-13T14:18:07Z',
|
||||||
|
'updated': '2011-04-13T14:18:07Z',
|
||||||
|
'virtualIps': [
|
||||||
|
{
|
||||||
|
'address': '3ffe:1900:4545:3:200:f8ff:fe21:67cf',
|
||||||
|
'id': '39',
|
||||||
|
'type': 'PUBLIC',
|
||||||
|
'ipVersion': 'IPV6'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'nodes': [
|
||||||
|
{
|
||||||
|
'address': '10.1.1.1',
|
||||||
|
'id': '653',
|
||||||
|
'port': '80',
|
||||||
|
'status': 'ONLINE',
|
||||||
|
'condition': 'ENABLED'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""virtualips"""
|
||||||
|
virtualips = {
|
||||||
|
'virtualIps': [
|
||||||
|
{
|
||||||
|
'id': '1021',
|
||||||
|
'address': '206.10.10.210',
|
||||||
|
'type': 'PUBLIC',
|
||||||
|
'ipVersion': 'IPV4'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""usage"""
|
||||||
|
usage = {
|
||||||
|
'loadBalancerUsageRecords': [
|
||||||
|
{
|
||||||
|
'id': '394',
|
||||||
|
'transferBytesIn': '2819204',
|
||||||
|
'transferBytesOut': '84923069'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '473',
|
||||||
|
'transferBytesIn': '0',
|
||||||
|
'transferBytesOut': '0'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""class HealthMonitor
|
||||||
|
monitor responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
class HealthMonitor(object):
|
||||||
|
"""monitor CONNECT response"""
|
||||||
|
get = {
|
||||||
|
'type': 'CONNECT',
|
||||||
|
'delay': '20',
|
||||||
|
'timeout': '10',
|
||||||
|
'attemptsBeforeDeactivation': '3'
|
||||||
|
}
|
||||||
|
"""monitor HTTPS response"""
|
||||||
|
get_https = {
|
||||||
|
'type': 'HTTPS',
|
||||||
|
'delay': '10',
|
||||||
|
'timeout': '3',
|
||||||
|
'attemptsBeforeDeactivation': '3',
|
||||||
|
'path': '/healthcheck'
|
||||||
|
}
|
||||||
|
"""class SessionPersistence
|
||||||
|
for managing Session Persistance
|
||||||
|
"""
|
||||||
|
|
||||||
|
class SessionPersistence(object):
|
||||||
|
"""get"""
|
||||||
|
get = {
|
||||||
|
'persistenceType': 'HTTP_COOKIE'
|
||||||
|
}
|
||||||
|
"""class Connections
|
||||||
|
Throttle Connections responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
class ConnectionThrottle(object):
|
||||||
|
"""get"""
|
||||||
|
get = {
|
||||||
|
'maxRequestRate': '50',
|
||||||
|
'rateInterval': '60'
|
||||||
|
}
|
||||||
|
|
||||||
|
"""class Nodes
|
||||||
|
grouping of node related responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Nodes(object):
|
||||||
|
"""list of nodes of a specific lb"""
|
||||||
|
get = {
|
||||||
|
'nodes': [
|
||||||
|
{
|
||||||
|
'id': '410',
|
||||||
|
'address': '10.1.1.1',
|
||||||
|
'port': '80',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'ONLINE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '236',
|
||||||
|
'address': '10.1.1.2',
|
||||||
|
'port': '80',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'ONLINE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '2815',
|
||||||
|
'address': '10.1.1.3',
|
||||||
|
'port': '83',
|
||||||
|
'condition': 'DISABLED',
|
||||||
|
'status': 'OFFLINE'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""a specific node details"""
|
||||||
|
get_detail = {
|
||||||
|
'id': '236',
|
||||||
|
'address': '10.1.1.2',
|
||||||
|
'port': '80',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'ONLINE'
|
||||||
|
}
|
||||||
|
|
||||||
|
"""nodes create response"""
|
||||||
|
post = {
|
||||||
|
'nodes': [
|
||||||
|
{
|
||||||
|
'id': '7298',
|
||||||
|
'address': '10.1.1.1',
|
||||||
|
'port': '80',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'ONLINE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '293',
|
||||||
|
'address': '10.2.2.1',
|
||||||
|
'port': '80',
|
||||||
|
'weight': '2',
|
||||||
|
'condition': 'ENABLED',
|
||||||
|
'status': 'OFFLINE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '183',
|
||||||
|
'address': '10.2.2.4',
|
||||||
|
'port': '88',
|
||||||
|
'weight': '2',
|
||||||
|
'condition': 'DISABLED',
|
||||||
|
'status': 'OFFLINE'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
61
libra/api/model/validation.py
Normal file
61
libra/api/model/validation.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
|
||||||
|
class Validation(object):
|
||||||
|
"""class Validatoin
|
||||||
|
Validation templates for validict lib
|
||||||
|
"""
|
||||||
|
"""loadbalancer_create"""
|
||||||
|
loadbalancer_create = {
|
||||||
|
"name": "a-new-loadbalancer",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"address": "10.1.1.1",
|
||||||
|
"port": "80"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"address": "10.1.1.2",
|
||||||
|
"port": "81"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""nodes_create"""
|
||||||
|
nodes_create = {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"address": "10.1.1.1",
|
||||||
|
"port": "80"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"address": "10.2.2.1",
|
||||||
|
"port": "80",
|
||||||
|
"weight": "2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"address": "10.2.2.2",
|
||||||
|
"port": "88",
|
||||||
|
"condition": "DISABLED",
|
||||||
|
"weight": "2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""monitor CONNECT request"""
|
||||||
|
monitor_connect = {
|
||||||
|
"type": "CONNECT",
|
||||||
|
"delay": "20",
|
||||||
|
"timeout": "10",
|
||||||
|
"attemptsBeforeDeactivation": "3"
|
||||||
|
}
|
12
libra/api/templates/error.html
Normal file
12
libra/api/templates/error.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<%inherit file="layout.html" />
|
||||||
|
|
||||||
|
## provide definitions for blocks we want to redefine
|
||||||
|
<%def name="title()">
|
||||||
|
Server Error ${status}
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
## now define the body of the template
|
||||||
|
<header>
|
||||||
|
<h1>Server Error ${status}</h1>
|
||||||
|
</header>
|
||||||
|
<p>${message}</p>
|
36
libra/api/tests/__init__.py
Normal file
36
libra/api/tests/__init__.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest import TestCase
|
||||||
|
from pecan import set_config
|
||||||
|
from pecan.testing import load_test_app
|
||||||
|
|
||||||
|
__all__ = ['FunctionalTest']
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionalTest(TestCase):
|
||||||
|
"""
|
||||||
|
Used for functional tests where you need to test your
|
||||||
|
literal application and its integration with the framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.app = load_test_app(os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
'config.py'
|
||||||
|
))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
set_config({}, overwrite=True)
|
50
libra/api/tests/config.py
Normal file
50
libra/api/tests/config.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Server Specific Configurations
|
||||||
|
server = {
|
||||||
|
'port': '8080',
|
||||||
|
'host': '0.0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pecan Application Configurations
|
||||||
|
app = {
|
||||||
|
'root': 'libra.api.controllers.root.RootController',
|
||||||
|
'modules': ['libra.api'],
|
||||||
|
'static_root': '%(confdir)s/../../public',
|
||||||
|
'template_path': '%(confdir)s/../templates',
|
||||||
|
'debug': True,
|
||||||
|
'errors': {
|
||||||
|
'404': '/error/404',
|
||||||
|
'__force_dict__': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database = {
|
||||||
|
'username':'root',
|
||||||
|
'password':'',
|
||||||
|
'host':'127.0.0.1',
|
||||||
|
'schema':'lbaas'
|
||||||
|
}
|
||||||
|
|
||||||
|
gearman = {
|
||||||
|
'server':['localhost:4730'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom Configurations must be in Python dictionary format::
|
||||||
|
#
|
||||||
|
# foo = {'bar':'baz'}
|
||||||
|
#
|
||||||
|
# All configurations are accessible at::
|
||||||
|
# pecan.conf
|
36
libra/api/tests/test_functional.py
Normal file
36
libra/api/tests/test_functional.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
#from unittest import TestCase
|
||||||
|
#from webtest import TestApp
|
||||||
|
from libra.api.tests import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootController(FunctionalTest):
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
response = self.app.get('/')
|
||||||
|
assert response.status_int == 201
|
||||||
|
|
||||||
|
def test_search(self):
|
||||||
|
response = self.app.post('/', params={'q': 'RestController'})
|
||||||
|
assert response.status_int == 201
|
||||||
|
# assert response.headers['Location'] == (
|
||||||
|
# 'http://pecan.readthedocs.org/en/latest/search.html'
|
||||||
|
# '?q=RestController'
|
||||||
|
# )
|
||||||
|
|
||||||
|
def test_get_not_found(self):
|
||||||
|
response = self.app.get('/a/bogus/url', expect_errors=True)
|
||||||
|
# assert response.status_int == 400
|
21
libra/api/tests/test_units.py
Normal file
21
libra/api/tests/test_units.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnits(TestCase):
|
||||||
|
|
||||||
|
def test_units(self):
|
||||||
|
assert 5 * 5 == 25
|
167
libra/openstack/common/jsonutils.py
Normal file
167
libra/openstack/common/jsonutils.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2010 United States Government as represented by the
|
||||||
|
# Administrator of the National Aeronautics and Space Administration.
|
||||||
|
# Copyright 2011 Justin Santa Barbara
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
'''
|
||||||
|
JSON related utilities.
|
||||||
|
|
||||||
|
This module provides a few things:
|
||||||
|
|
||||||
|
1) A handy function for getting an object down to something that can be
|
||||||
|
JSON serialized. See to_primitive().
|
||||||
|
|
||||||
|
2) Wrappers around loads() and dumps(). The dumps() wrapper will
|
||||||
|
automatically use to_primitive() for you if needed.
|
||||||
|
|
||||||
|
3) This sets up anyjson to use the loads() and dumps() wrappers if anyjson
|
||||||
|
is available.
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import types
|
||||||
|
import xmlrpclib
|
||||||
|
|
||||||
|
from libra.openstack.common import timeutils
|
||||||
|
|
||||||
|
|
||||||
|
_nasty_type_tests = [inspect.ismodule, inspect.isclass, inspect.ismethod,
|
||||||
|
inspect.isfunction, inspect.isgeneratorfunction,
|
||||||
|
inspect.isgenerator, inspect.istraceback, inspect.isframe,
|
||||||
|
inspect.iscode, inspect.isbuiltin, inspect.isroutine,
|
||||||
|
inspect.isabstract]
|
||||||
|
|
||||||
|
_simple_types = (types.NoneType, int, basestring, bool, float, long)
|
||||||
|
|
||||||
|
|
||||||
|
def to_primitive(value, convert_instances=False, convert_datetime=True,
|
||||||
|
level=0, max_depth=3):
|
||||||
|
"""Convert a complex object into primitives.
|
||||||
|
|
||||||
|
Handy for JSON serialization. We can optionally handle instances,
|
||||||
|
but since this is a recursive function, we could have cyclical
|
||||||
|
data structures.
|
||||||
|
|
||||||
|
To handle cyclical data structures we could track the actual objects
|
||||||
|
visited in a set, but not all objects are hashable. Instead we just
|
||||||
|
track the depth of the object inspections and don't go too deep.
|
||||||
|
|
||||||
|
Therefore, convert_instances=True is lossy ... be aware.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# handle obvious types first - order of basic types determined by running
|
||||||
|
# full tests on nova project, resulting in the following counts:
|
||||||
|
# 572754 <type 'NoneType'>
|
||||||
|
# 460353 <type 'int'>
|
||||||
|
# 379632 <type 'unicode'>
|
||||||
|
# 274610 <type 'str'>
|
||||||
|
# 199918 <type 'dict'>
|
||||||
|
# 114200 <type 'datetime.datetime'>
|
||||||
|
# 51817 <type 'bool'>
|
||||||
|
# 26164 <type 'list'>
|
||||||
|
# 6491 <type 'float'>
|
||||||
|
# 283 <type 'tuple'>
|
||||||
|
# 19 <type 'long'>
|
||||||
|
if isinstance(value, _simple_types):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
if convert_datetime:
|
||||||
|
return timeutils.strtime(value)
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# value of itertools.count doesn't get caught by nasty_type_tests
|
||||||
|
# and results in infinite loop when list(value) is called.
|
||||||
|
if type(value) == itertools.count:
|
||||||
|
return unicode(value)
|
||||||
|
|
||||||
|
# FIXME(vish): Workaround for LP bug 852095. Without this workaround,
|
||||||
|
# tests that raise an exception in a mocked method that
|
||||||
|
# has a @wrap_exception with a notifier will fail. If
|
||||||
|
# we up the dependency to 0.5.4 (when it is released) we
|
||||||
|
# can remove this workaround.
|
||||||
|
if getattr(value, '__module__', None) == 'mox':
|
||||||
|
return 'mock'
|
||||||
|
|
||||||
|
if level > max_depth:
|
||||||
|
return '?'
|
||||||
|
|
||||||
|
# The try block may not be necessary after the class check above,
|
||||||
|
# but just in case ...
|
||||||
|
try:
|
||||||
|
recursive = functools.partial(to_primitive,
|
||||||
|
convert_instances=convert_instances,
|
||||||
|
convert_datetime=convert_datetime,
|
||||||
|
level=level,
|
||||||
|
max_depth=max_depth)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict((k, recursive(v)) for k, v in value.iteritems())
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
return [recursive(lv) for lv in value]
|
||||||
|
|
||||||
|
# It's not clear why xmlrpclib created their own DateTime type, but
|
||||||
|
# for our purposes, make it a datetime type which is explicitly
|
||||||
|
# handled
|
||||||
|
if isinstance(value, xmlrpclib.DateTime):
|
||||||
|
value = datetime.datetime(*tuple(value.timetuple())[:6])
|
||||||
|
|
||||||
|
if convert_datetime and isinstance(value, datetime.datetime):
|
||||||
|
return timeutils.strtime(value)
|
||||||
|
elif hasattr(value, 'iteritems'):
|
||||||
|
return recursive(dict(value.iteritems()), level=level + 1)
|
||||||
|
elif hasattr(value, '__iter__'):
|
||||||
|
return recursive(list(value))
|
||||||
|
elif convert_instances and hasattr(value, '__dict__'):
|
||||||
|
# Likely an instance of something. Watch for cycles.
|
||||||
|
# Ignore class member vars.
|
||||||
|
return recursive(value.__dict__, level=level + 1)
|
||||||
|
else:
|
||||||
|
if any(test(value) for test in _nasty_type_tests):
|
||||||
|
return unicode(value)
|
||||||
|
return value
|
||||||
|
except TypeError:
|
||||||
|
# Class objects are tricky since they may define something like
|
||||||
|
# __iter__ defined but it isn't callable as list().
|
||||||
|
return unicode(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(value, default=to_primitive, **kwargs):
|
||||||
|
return json.dumps(value, default=default, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def loads(s):
|
||||||
|
return json.loads(s)
|
||||||
|
|
||||||
|
|
||||||
|
def load(s):
|
||||||
|
return json.load(s)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import anyjson
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
anyjson._modules.append((__name__, 'dumps', TypeError,
|
||||||
|
'loads', ValueError, 'load'))
|
||||||
|
anyjson.force_implementation(__name__)
|
186
libra/openstack/common/timeutils.py
Normal file
186
libra/openstack/common/timeutils.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2011 OpenStack Foundation.
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Time related utilities and helper functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import iso8601
|
||||||
|
|
||||||
|
|
||||||
|
# ISO 8601 extended time format with microseconds
|
||||||
|
_ISO8601_TIME_FORMAT_SUBSECOND = '%Y-%m-%dT%H:%M:%S.%f'
|
||||||
|
_ISO8601_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S'
|
||||||
|
PERFECT_TIME_FORMAT = _ISO8601_TIME_FORMAT_SUBSECOND
|
||||||
|
|
||||||
|
|
||||||
|
def isotime(at=None, subsecond=False):
|
||||||
|
"""Stringify time in ISO 8601 format"""
|
||||||
|
if not at:
|
||||||
|
at = utcnow()
|
||||||
|
st = at.strftime(_ISO8601_TIME_FORMAT
|
||||||
|
if not subsecond
|
||||||
|
else _ISO8601_TIME_FORMAT_SUBSECOND)
|
||||||
|
tz = at.tzinfo.tzname(None) if at.tzinfo else 'UTC'
|
||||||
|
st += ('Z' if tz == 'UTC' else tz)
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def parse_isotime(timestr):
|
||||||
|
"""Parse time from ISO 8601 format"""
|
||||||
|
try:
|
||||||
|
return iso8601.parse_date(timestr)
|
||||||
|
except iso8601.ParseError as e:
|
||||||
|
raise ValueError(e.message)
|
||||||
|
except TypeError as e:
|
||||||
|
raise ValueError(e.message)
|
||||||
|
|
||||||
|
|
||||||
|
def strtime(at=None, fmt=PERFECT_TIME_FORMAT):
|
||||||
|
"""Returns formatted utcnow."""
|
||||||
|
if not at:
|
||||||
|
at = utcnow()
|
||||||
|
return at.strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_strtime(timestr, fmt=PERFECT_TIME_FORMAT):
|
||||||
|
"""Turn a formatted time back into a datetime."""
|
||||||
|
return datetime.datetime.strptime(timestr, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_time(timestamp):
|
||||||
|
"""Normalize time in arbitrary timezone to UTC naive object"""
|
||||||
|
offset = timestamp.utcoffset()
|
||||||
|
if offset is None:
|
||||||
|
return timestamp
|
||||||
|
return timestamp.replace(tzinfo=None) - offset
|
||||||
|
|
||||||
|
|
||||||
|
def is_older_than(before, seconds):
|
||||||
|
"""Return True if before is older than seconds."""
|
||||||
|
if isinstance(before, basestring):
|
||||||
|
before = parse_strtime(before).replace(tzinfo=None)
|
||||||
|
return utcnow() - before > datetime.timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def is_newer_than(after, seconds):
|
||||||
|
"""Return True if after is newer than seconds."""
|
||||||
|
if isinstance(after, basestring):
|
||||||
|
after = parse_strtime(after).replace(tzinfo=None)
|
||||||
|
return after - utcnow() > datetime.timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow_ts():
|
||||||
|
"""Timestamp version of our utcnow function."""
|
||||||
|
return calendar.timegm(utcnow().timetuple())
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow():
|
||||||
|
"""Overridable version of utils.utcnow."""
|
||||||
|
if utcnow.override_time:
|
||||||
|
try:
|
||||||
|
return utcnow.override_time.pop(0)
|
||||||
|
except AttributeError:
|
||||||
|
return utcnow.override_time
|
||||||
|
return datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def iso8601_from_timestamp(timestamp):
|
||||||
|
"""Returns a iso8601 formated date from timestamp"""
|
||||||
|
return isotime(datetime.datetime.utcfromtimestamp(timestamp))
|
||||||
|
|
||||||
|
|
||||||
|
utcnow.override_time = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_time_override(override_time=datetime.datetime.utcnow()):
|
||||||
|
"""
|
||||||
|
Override utils.utcnow to return a constant time or a list thereof,
|
||||||
|
one at a time.
|
||||||
|
"""
|
||||||
|
utcnow.override_time = override_time
|
||||||
|
|
||||||
|
|
||||||
|
def advance_time_delta(timedelta):
|
||||||
|
"""Advance overridden time using a datetime.timedelta."""
|
||||||
|
assert(not utcnow.override_time is None)
|
||||||
|
try:
|
||||||
|
for dt in utcnow.override_time:
|
||||||
|
dt += timedelta
|
||||||
|
except TypeError:
|
||||||
|
utcnow.override_time += timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def advance_time_seconds(seconds):
|
||||||
|
"""Advance overridden time by seconds."""
|
||||||
|
advance_time_delta(datetime.timedelta(0, seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_time_override():
|
||||||
|
"""Remove the overridden time."""
|
||||||
|
utcnow.override_time = None
|
||||||
|
|
||||||
|
|
||||||
|
def marshall_now(now=None):
|
||||||
|
"""Make an rpc-safe datetime with microseconds.
|
||||||
|
|
||||||
|
Note: tzinfo is stripped, but not required for relative times."""
|
||||||
|
if not now:
|
||||||
|
now = utcnow()
|
||||||
|
return dict(day=now.day, month=now.month, year=now.year, hour=now.hour,
|
||||||
|
minute=now.minute, second=now.second,
|
||||||
|
microsecond=now.microsecond)
|
||||||
|
|
||||||
|
|
||||||
|
def unmarshall_time(tyme):
|
||||||
|
"""Unmarshall a datetime dict."""
|
||||||
|
return datetime.datetime(day=tyme['day'],
|
||||||
|
month=tyme['month'],
|
||||||
|
year=tyme['year'],
|
||||||
|
hour=tyme['hour'],
|
||||||
|
minute=tyme['minute'],
|
||||||
|
second=tyme['second'],
|
||||||
|
microsecond=tyme['microsecond'])
|
||||||
|
|
||||||
|
|
||||||
|
def delta_seconds(before, after):
|
||||||
|
"""
|
||||||
|
Compute the difference in seconds between two date, time, or
|
||||||
|
datetime objects (as a float, to microsecond resolution).
|
||||||
|
"""
|
||||||
|
delta = after - before
|
||||||
|
try:
|
||||||
|
return delta.total_seconds()
|
||||||
|
except AttributeError:
|
||||||
|
return ((delta.days * 24 * 3600) + delta.seconds +
|
||||||
|
float(delta.microseconds) / (10 ** 6))
|
||||||
|
|
||||||
|
|
||||||
|
def is_soon(dt, window):
|
||||||
|
"""
|
||||||
|
Determines if time is going to happen in the next window seconds.
|
||||||
|
|
||||||
|
:params dt: the time
|
||||||
|
:params window: minimum seconds to remain to consider the time not soon
|
||||||
|
|
||||||
|
:return: True if expiration is within the given duration
|
||||||
|
"""
|
||||||
|
soon = (utcnow() + datetime.timedelta(seconds=window))
|
||||||
|
return normalize_time(dt) <= soon
|
74
libra/openstack/common/xmlutils.py
Normal file
74
libra/openstack/common/xmlutils.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2013 IBM Corp.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from xml.dom import minidom
|
||||||
|
from xml.parsers import expat
|
||||||
|
from xml import sax
|
||||||
|
from xml.sax import expatreader
|
||||||
|
|
||||||
|
|
||||||
|
class ProtectedExpatParser(expatreader.ExpatParser):
|
||||||
|
"""An expat parser which disables DTD's and entities by default."""
|
||||||
|
|
||||||
|
def __init__(self, forbid_dtd=True, forbid_entities=True,
|
||||||
|
*args, **kwargs):
|
||||||
|
# Python 2.x old style class
|
||||||
|
expatreader.ExpatParser.__init__(self, *args, **kwargs)
|
||||||
|
self.forbid_dtd = forbid_dtd
|
||||||
|
self.forbid_entities = forbid_entities
|
||||||
|
|
||||||
|
def start_doctype_decl(self, name, sysid, pubid, has_internal_subset):
|
||||||
|
raise ValueError("Inline DTD forbidden")
|
||||||
|
|
||||||
|
def entity_decl(self, entityName, is_parameter_entity, value, base,
|
||||||
|
systemId, publicId, notationName):
|
||||||
|
raise ValueError("<!ENTITY> entity declaration forbidden")
|
||||||
|
|
||||||
|
def unparsed_entity_decl(self, name, base, sysid, pubid, notation_name):
|
||||||
|
# expat 1.2
|
||||||
|
raise ValueError("<!ENTITY> unparsed entity forbidden")
|
||||||
|
|
||||||
|
def external_entity_ref(self, context, base, systemId, publicId):
|
||||||
|
raise ValueError("<!ENTITY> external entity forbidden")
|
||||||
|
|
||||||
|
def notation_decl(self, name, base, sysid, pubid):
|
||||||
|
raise ValueError("<!ENTITY> notation forbidden")
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
expatreader.ExpatParser.reset(self)
|
||||||
|
if self.forbid_dtd:
|
||||||
|
self._parser.StartDoctypeDeclHandler = self.start_doctype_decl
|
||||||
|
self._parser.EndDoctypeDeclHandler = None
|
||||||
|
if self.forbid_entities:
|
||||||
|
self._parser.EntityDeclHandler = self.entity_decl
|
||||||
|
self._parser.UnparsedEntityDeclHandler = self.unparsed_entity_decl
|
||||||
|
self._parser.ExternalEntityRefHandler = self.external_entity_ref
|
||||||
|
self._parser.NotationDeclHandler = self.notation_decl
|
||||||
|
try:
|
||||||
|
self._parser.SkippedEntityHandler = None
|
||||||
|
except AttributeError:
|
||||||
|
# some pyexpat versions do not support SkippedEntity
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def safe_minidom_parse_string(xml_string):
|
||||||
|
"""Parse an XML string using minidom safely.
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return minidom.parseString(xml_string, parser=ProtectedExpatParser())
|
||||||
|
except sax.SAXParseException:
|
||||||
|
raise expat.ExpatError()
|
@ -1,7 +1,7 @@
|
|||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
|
|
||||||
# The list of modules to copy from openstack-common
|
# The list of modules to copy from openstack-common
|
||||||
modules=importutils
|
modules=importutils,jsonutils,xmlutils
|
||||||
|
|
||||||
# The base module to hold the copy of openstack.common
|
# The base module to hold the copy of openstack.common
|
||||||
base=libra
|
base=libra
|
||||||
|
@ -6,3 +6,6 @@ python_novaclient>=2.11.1
|
|||||||
python_swiftclient>=1.3.0
|
python_swiftclient>=1.3.0
|
||||||
requests>=1.0.0
|
requests>=1.0.0
|
||||||
dogapi
|
dogapi
|
||||||
|
pecan
|
||||||
|
sqlalchemy
|
||||||
|
MySQL-python
|
||||||
|
Loading…
x
Reference in New Issue
Block a user