I have uploaded a patch to JIRA containing a new driver to implement
DreamHost Private Servers in Libcloud.  I uploaded two files
containing the same patch in different formats:
"dreamhost_driver.patch" contains the output from `git-format-patch`,
and "dreamhost_driver_squash.patch" contains the output from `git
diff` so you can use whichever is easier to review and incorporate.  I
am also attaching the squashed patch to the list for anyone who is
interested.

The DreamHost driver implements the interface required for node_driver
except for list_sizes() and list_locations().  A DreamHost PS is a
linux vserver based virtual private server and can be resized to any
integer value between 300MB and 4000MB.  Users are currently unable to
specify a location for their private server.

There are some other elements of the DreamHost Private Server API that
are not exposed by this driver and I was wondering if I should expose
them.  They include listing and changing various configuration
settings that are available for our private servers and listing
usage/reboot history.  Now that the guts of the driver are written
adding this functionality should not be difficult if you think it
appropriate to do so.

Let me know if you have any further questions or comments about this.

~Kyle Marsh
DreamHost Developer
[email protected]
diff --git a/libcloud/drivers/dreamhost.py b/libcloud/drivers/dreamhost.py
new file mode 100644
index 0000000..5260d5d
--- /dev/null
+++ b/libcloud/drivers/dreamhost.py
@@ -0,0 +1,180 @@
+# 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
+
+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
+
+	def create_node(self, **kwargs):
+		params = {
+			'cmd' : 'dreamhost_ps-add_ps',
+			'movedata' : kwargs['movedata'],
+			'type' : kwargs['type'].name
+		}
+		data = self.connection.request('/', params).object
+		return Node(
+			id = data['added_' + kwargs['type'].name],
+			name = data['added_' + kwargs['type'].name],
+			state = NodeState.PENDING,
+			public_ip = [],
+			private_ip = [],
+			driver = self.connection.driver,
+			extra = {
+				'type' : kwargs['type'].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 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 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):
+		raise NotImplementedError, \
+			'DreamHost Private Servers can be any size between 150MB and 4000MB'
+
+	def list_locations(self, **kwargs):
+		raise NotImplementedError, \
+			'You cannot select a location for DreamHost Private Servers at this time.'
+
+	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 2c62c8a..1e6bf52 100644
--- a/libcloud/providers.py
+++ b/libcloud/providers.py
@@ -43,6 +43,8 @@ DRIVERS = {
         ('libcloud.drivers.voxel', 'VoxelNodeDriver'),
     Provider.SOFTLAYER:
         ('libcloud.drivers.softlayer', 'SoftLayerNodeDriver'),
+    Provider.DREAMHOST:
+        ('libcloud.drivers.dreamhost', 'DreamhostNodeDriver'),
 }
 
 def get_driver(provider):
diff --git a/libcloud/types.py b/libcloud/types.py
index ab60eee..0a41784 100644
--- a/libcloud/types.py
+++ b/libcloud/types.py
@@ -31,6 +31,7 @@ class Provider(object):
     @cvar LINODE: Linode.com
     @cvar VCLOUD: vmware vCloud
     @cvar RIMUHOSTING: RimuHosting.com
+    @cvar DREAMHOST: DreamHost Private Server
     """
     DUMMY = 0
     EC2 = 1  # deprecated name
@@ -47,6 +48,7 @@ class Provider(object):
     EC2_US_WEST = 10
     VOXEL = 11
     SOFTLAYER = 12
+    DREAMHOST = 13
 
 class NodeState(object):
     """
diff --git a/test/__init__.py b/test/__init__.py
index 04072ab..1a9158b 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..1f633a0
--- /dev/null
+++ b/test/test_dreamhost.py
@@ -0,0 +1,270 @@
+# 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(
+			type = self.driver.list_images()[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):
+		try:
+			self.driver.list_sizes()
+		except NotImplementedError:
+			pass
+
+	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())
+

Reply via email to