The Dreamhost Driver patch has been updated as per Jeremy's suggestions. Just like last time, there are two files, "dreamhost_driver.patch" containing the output from `git-format-patch` and "dreamhost_driver_squash.patch" containing the output from `git-diff`. The squashed patch is attached here, as well.
Any further comments would be appreciated. ~Kyle Marsh
diff --git a/libcloud/drivers/dreamhost.py b/libcloud/drivers/dreamhost.py new file mode 100644 index 0000000..9001de6 --- /dev/null +++ b/libcloud/drivers/dreamhost.py @@ -0,0 +1,234 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# libcloud.org licenses this file to You 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. +""" +DreamHost Driver +""" +from libcloud.interface import INodeDriver +from libcloud.base import ConnectionKey, Response, NodeDriver, Node +from libcloud.base import NodeSize, NodeImage, NodeLocation +from libcloud.types import Provider, NodeState, InvalidCredsException +#from zope.interface import implements +import uuid + +# JSON is included in the standard library starting with Python 2.6. For 2.5 +# and 2.4, there's a simplejson egg at: http://pypi.python.org/pypi/simplejson +try: import json +except: import simplejson as json + +""" +DreamHost Private Servers can be resized on the fly, but Libcloud doesn't +currently support extensions to its interface, so we'll put some basic sizes +in for node creation. +""" +DH_PS_SIZES = { + 'minimum': { + 'id' : 'minimum', + 'name' : 'Minimum DH PS size', + 'ram' : 300, + 'price' : 15, + 'disk' : None, + 'bandwidth' : None + }, + 'maximum': { + 'id' : 'maximum', + 'name' : 'Maximum DH PS size', + 'ram' : 4000, + 'price' : 200, + 'disk' : None, + 'bandwidth' : None + }, + 'default': { + 'id' : 'default', + 'name' : 'Default DH PS size', + 'ram' : 2300, + 'price' : 115, + 'disk' : None, + 'bandwidth' : None + }, + 'low': { + 'id' : 'low', + 'name' : 'DH PS with 1GB RAM', + 'ram' : 1000, + 'price' : 50, + 'disk' : None, + 'bandwidth' : None + }, + 'high': { + 'id' : 'high', + 'name' : 'DH PS with 3GB RAM', + 'ram' : 3000, + 'price' : 150, + 'disk' : None, + 'bandwidth' : None + }, +} + +class DreamhostAPIException(Exception): + def __str__(self): + return self.args[0] + def __repr__(self): + return "<DreamhostException '%s'>" % (self.args[0]) + +class DreamhostResponse(Response): + """ + Response class for DreamHost PS + """ + + def parse_body(self): + resp = json.loads(self.body) + if resp['result'] != 'success': + raise Exception(self.api_parse_error(resp)) + return resp['data'] + + def parse_error(self): + raise Exception + + def api_parse_error(self, response): + if response.has_key('data'): + if response['data'] == 'invalid_api_key': + raise InvalidCredsException, "Oops! You've entered an invalid API key" + else: + raise DreamhostAPIException(response['data']) + else: + raise DreamhostAPIException("Unknown problem: %s" % (self.body)) + +class DreamhostConnection(ConnectionKey): + """ + Connection class to connect to DreamHost's API servers + """ + + host = 'api.dreamhost.com' + responseCls = DreamhostResponse + format = 'json' + + def add_default_params(self, params): + """ + Add key and format parameters to the request. Eventually should add + unique_id to prevent re-execution of a single request. + """ + params['key'] = self.key + params['format'] = self.format + #params['unique_id'] = generate_unique_id() + return params + + +class DreamhostNodeDriver(NodeDriver): + """ + Node Driver for DreamHost PS + """ + type = Provider.DREAMHOST + name = "Dreamhost" + connectionCls = DreamhostConnection + _sizes = DH_PS_SIZES + + def create_node(self, **kwargs): + size = kwargs['size'].ram + params = { + 'cmd' : 'dreamhost_ps-add_ps', + 'movedata' : kwargs['movedata'], + 'type' : kwargs['image'].name, + 'size' : size + } + data = self.connection.request('/', params).object + return Node( + id = data['added_' + kwargs['image'].name], + name = data['added_' + kwargs['image'].name], + state = NodeState.PENDING, + public_ip = [], + private_ip = [], + driver = self.connection.driver, + extra = { + 'type' : kwargs['image'].name + } + ) + + def destroy_node(self, node): + params = { + 'cmd' : 'dreamhost_ps-remove_ps', + 'ps' : node.id + } + try: + return self.connection.request('/', params).success() + except DreamhostAPIException: + return False + + def reboot_node(self, node): + params = { + 'cmd' : 'dreamhost_ps-reboot', + 'ps' : node.id + } + try: + return self.connection.request('/', params).success() + except DreamhostAPIException: + return False + + def list_nodes(self, **kwargs): + data = self.connection.request('/', { 'cmd' : 'dreamhost_ps-list_ps' }).object + return [self._to_node(n) for n in data] + + def list_images(self, **kwargs): + data = self.connection.request('/', { 'cmd' : 'dreamhost_ps-list_images' }).object + images = [] + for img in data: + images.append(NodeImage( + id = img['image'], + name = img['image'], + driver = self.connection.driver + )) + return images + + def list_sizes(self, **kwargs): + return [ NodeSize(driver=self.connection.driver, **i) + for i in self._sizes.values() ] + + def list_locations(self, **kwargs): + raise NotImplementedError, \ + 'You cannot select a location for DreamHost Private Servers at this time.' + + ############################################ + # Private Methods (helpers and extensions) # + ############################################ + def _resize_node(self, node, size): + if (size < 300 or size > 4000): + return False + + params = { + 'cmd' : 'dreamhost_ps-set_size', + 'ps' : node.id, + 'size' : size + } + try: + return self.connection.request('/', params).success() + except DreamhostAPIException: + return False + + def _to_node(self, data): + """ + Convert the data from a DreamhostResponse object into a Node + """ + return Node( + id = data['ps'], + name = data['ps'], + state = NodeState.UNKNOWN, + public_ip = [data['ip']], + private_ip = [], + driver = self.connection.driver, + extra = { + 'current_size' : data['memory_mb'], + 'account_id' : data['account_id'], + 'type' : data['type'] + } + ) + diff --git a/libcloud/providers.py b/libcloud/providers.py index 3e5559e..e94f3e0 100644 --- a/libcloud/providers.py +++ b/libcloud/providers.py @@ -49,6 +49,8 @@ DRIVERS = { ('libcloud.drivers.ec2', 'EucNodeDriver'), Provider.IBM: ('libcloud.drivers.ibm', 'IBMNodeDriver'), + Provider.DREAMHOST: + ('libcloud.drivers.dreamhost', 'DreamhostNodeDriver'), } def get_driver(provider): diff --git a/libcloud/types.py b/libcloud/types.py index 85f358c..3b3781d 100644 --- a/libcloud/types.py +++ b/libcloud/types.py @@ -32,6 +32,7 @@ class Provider(object): @cvar VCLOUD: vmware vCloud @cvar RIMUHOSTING: RimuHosting.com @cvar ECP: Enomaly + @cvar DREAMHOST: DreamHost Private Server """ DUMMY = 0 EC2 = 1 # deprecated name @@ -51,6 +52,7 @@ class Provider(object): EUCALYPTUS = 13 ECP = 14 IBM = 15 + DREAMHOST = 16 class NodeState(object): """ diff --git a/test/__init__.py b/test/__init__.py index 2645fc6..9873810 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -119,7 +119,7 @@ class MockHttp(object): if self.type: meth_name = '%s_%s' % (meth_name, self.type) if self.use_param: - param = qs[self.use_param][0].replace('.', '_') + param = qs[self.use_param][0].replace('.', '_').replace('-','_') meth_name = '%s_%s' % (meth_name, param) meth = getattr(self, meth_name) status, body, headers, reason = meth(method, url, body, headers) diff --git a/test/test_dreamhost.py b/test/test_dreamhost.py new file mode 100644 index 0000000..8149eb2 --- /dev/null +++ b/test/test_dreamhost.py @@ -0,0 +1,275 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# libcloud.org licenses this file to You 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 sys +import unittest + +from libcloud.drivers.dreamhost import DreamhostNodeDriver +from libcloud.types import Provider, NodeState, InvalidCredsException +from libcloud.base import Node, NodeImage, NodeSize + +import httplib + +try: import json +except: import simplejson as json + +from test import MockHttp, multipleresponse, TestCaseMixin +from secrets import DREAMHOST_KEY + +#class DreamhostTest(unittest.TestCase, TestCaseMixin): +class DreamhostTest(unittest.TestCase): + + def setUp(self): + DreamhostNodeDriver.connectionCls.conn_classes = ( + None, + DreamhostMockHttp + ) + DreamhostMockHttp.type = None + DreamhostMockHttp.use_param = 'cmd' + self.driver = DreamhostNodeDriver('foo') + + def test_invalid_creds(self): + """ + Tests the error-handling for passing a bad API Key to the DreamHost API + """ + DreamhostMockHttp.type = 'BAD_AUTH' + try: + self.driver.list_nodes() + self.assertTrue(False) # Above command should have thrown an InvalidCredsException + except InvalidCredsException: + self.assertTrue(True) + + + def test_list_nodes(self): + """ + Test list_nodes for DreamHost PS driver. Should return a list of two nodes: + - account_id: 000000 + ip: 75.119.203.51 + memory_mb: 500 + ps: ps22174 + start_date: 2010-02-25 + type: web + - account_id: 000000 + ip: 75.119.203.52 + memory_mb: 1500 + ps: ps22175 + start_date: 2010-02-25 + type: mysql + """ + + nodes = self.driver.list_nodes() + self.assertEqual(len(nodes), 2) + web_node = nodes[0] + mysql_node = nodes[1] + + # Web node tests + self.assertEqual(web_node.id, 'ps22174') + self.assertEqual(web_node.state, NodeState.UNKNOWN) + self.assertTrue('75.119.203.51' in web_node.public_ip) + self.assertTrue( + web_node.extra.has_key('current_size') and + web_node.extra['current_size'] == 500 + ) + self.assertTrue( + web_node.extra.has_key('account_id') and + web_node.extra['account_id'] == 000000 + ) + self.assertTrue( + web_node.extra.has_key('type') and + web_node.extra['type'] == 'web' + ) + # MySql node tests + self.assertEqual(mysql_node.id, 'ps22175') + self.assertEqual(mysql_node.state, NodeState.UNKNOWN) + self.assertTrue('75.119.203.52' in mysql_node.public_ip) + self.assertTrue( + mysql_node.extra.has_key('current_size') and + mysql_node.extra['current_size'] == 1500 + ) + self.assertTrue( + mysql_node.extra.has_key('account_id') and + mysql_node.extra['account_id'] == 000000 + ) + self.assertTrue( + mysql_node.extra.has_key('type') and + mysql_node.extra['type'] == 'mysql' + ) + + def test_create_node(self): + """ + Test create_node for DreamHost PS driver. + This is not remarkably compatible with libcloud. The DH API allows + users to specify what image they want to create and whether to move + all their data to the (web) PS. It does NOT accept a name, size, or + location. The only information it returns is the PS's context id + Once the PS is ready it will appear in the list generated by list_ps. + """ + new_node = self.driver.create_node( + image = self.driver.list_images()[0], + size = self.driver.list_sizes()[0], + movedata = 'no', + ) + self.assertEqual(new_node.id, 'ps12345') + self.assertEqual(new_node.state, NodeState.PENDING) + self.assertTrue( + new_node.extra.has_key('type') and + new_node.extra['type'] == 'web' + ) + + def test_destroy_node(self): + """ + Test destroy_node for DreamHost PS driver + """ + node = self.driver.list_nodes()[0] + self.assertTrue(self.driver.destroy_node(node)) + + def test_destroy_node_failure(self): + """ + Test destroy_node failure for DreamHost PS driver + """ + node = self.driver.list_nodes()[0] + + DreamhostMockHttp.type = 'API_FAILURE' + self.assertFalse(self.driver.destroy_node(node)) + + def test_reboot_node(self): + """ + Test reboot_node for DreamHost PS driver. + """ + node = self.driver.list_nodes()[0] + self.assertTrue(self.driver.reboot_node(node)) + + def test_reboot_node_failure(self): + """ + Test reboot_node failure for DreamHost PS driver + """ + node = self.driver.list_nodes()[0] + + DreamhostMockHttp.type = 'API_FAILURE' + self.assertFalse(self.driver.reboot_node(node)) + + def test_resize_node(self): + """ + Test resize_node for DreamHost PS driver + """ + node = self.driver.list_nodes()[0] + self.assertTrue(self.driver._resize_node(node, 400)) + + def test_resize_node_failure(self): + """ + Test reboot_node faliure for DreamHost PS driver + """ + node = self.driver.list_nodes()[0] + + DreamhostMockHttp.type = 'API_FAILURE' + self.assertFalse(self.driver._resize_node(node, 400)) + + def test_list_images(self): + """ + Test list_images for DreamHost PS driver. + """ + images = self.driver.list_images() + self.assertEqual(len(images), 2) + self.assertEqual(images[0].id, 'web') + self.assertEqual(images[0].name, 'web') + self.assertEqual(images[1].id, 'mysql') + self.assertEqual(images[1].name, 'mysql') + + def test_list_sizes(self): + sizes = self.driver.list_sizes() + self.assertEqual(len(sizes), 5) + + self.assertEqual(sizes[0].id, 'default') + self.assertEqual(sizes[0].bandwidth, None) + self.assertEqual(sizes[0].disk, None) + self.assertEqual(sizes[0].ram, 2300) + self.assertEqual(sizes[0].price, 115) + + def test_list_locations(self): + try: + self.driver.list_locations() + except NotImplementedError: + pass + +class DreamhostMockHttp(MockHttp): + + def _BAD_AUTH_dreamhost_ps_list_ps(self, method, url, body, headers): + body = json.dumps({'data' : 'invalid_api_key', 'result' : 'error'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _dreamhost_ps_add_ps(self, method, url, body, headers): + body = json.dumps({'data' : {'added_web' : 'ps12345'}, 'result' : 'success'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _dreamhost_ps_list_ps(self, method, url, body, headers): + data = [{ + 'account_id' : 000000, + 'ip': '75.119.203.51', + 'memory_mb' : 500, + 'ps' : 'ps22174', + 'start_date' : '2010-02-25', + 'type' : 'web' + }, + { + 'account_id' : 000000, + 'ip' : '75.119.203.52', + 'memory_mb' : 1500, + 'ps' : 'ps22175', + 'start_date' : '2010-02-25', + 'type' : 'mysql' + }] + result = 'success' + body = json.dumps({'data' : data, 'result' : result}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _dreamhost_ps_list_images(self, method, url, body, headers): + data = [{ + 'description' : 'Private web server', + 'image' : 'web' + }, + { + 'description' : 'Private MySQL server', + 'image' : 'mysql' + }] + result = 'success' + body = json.dumps({'data' : data, 'result' : result}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _dreamhost_ps_reboot(self, method, url, body, headers): + body = json.dumps({'data' : 'reboot_scheduled', 'result' : 'success'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _API_FAILURE_dreamhost_ps_reboot(self, method, url, body, headers): + body = json.dumps({'data' : 'no_such_ps', 'result' : 'error'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _dreamhost_ps_set_size(self, method, url, body, headers): + body = json.dumps({'data' : {'memory-mb' : '500'}, 'result' : 'success'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _API_FAILURE_dreamhost_ps_set_size(self, method, url, body, headers): + body = json.dumps({'data' : 'internal_error_setting_size', 'result' : 'error'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _dreamhost_ps_remove_ps(self, method, url, body, headers): + body = json.dumps({'data' : 'removed_web', 'result' : 'success'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _API_FAILURE_dreamhost_ps_remove_ps(self, method, url, body, headers): + body = json.dumps({'data' : 'no_such_ps', 'result' : 'error'}) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + +if __name__ == '__main__': + sys.exit(unittest.main()) +
