Repository: libcloud Updated Branches: refs/heads/trunk 75b129e8b -> b2662d529
An initial implementation for the cloudscale.ch API. Project: http://git-wip-us.apache.org/repos/asf/libcloud/repo Commit: http://git-wip-us.apache.org/repos/asf/libcloud/commit/c6ad7015 Tree: http://git-wip-us.apache.org/repos/asf/libcloud/tree/c6ad7015 Diff: http://git-wip-us.apache.org/repos/asf/libcloud/diff/c6ad7015 Branch: refs/heads/trunk Commit: c6ad7015a7adb181b867c6b1e848262d2ba52cf3 Parents: fe34a54 Author: Dave Halter <davidhalte...@gmail.com> Authored: Tue Nov 22 13:50:41 2016 +0100 Committer: Dave Halter <davidhalte...@gmail.com> Committed: Tue Nov 22 13:50:41 2016 +0100 ---------------------------------------------------------------------- libcloud/compute/drivers/cloudscale.py | 211 +++++++++++++++++++ libcloud/compute/providers.py | 2 + libcloud/compute/types.py | 2 + .../fixtures/cloudscale/create_node.json | 46 ++++ .../fixtures/cloudscale/list_images.json | 1 + .../compute/fixtures/cloudscale/list_nodes.json | 48 +++++ .../compute/fixtures/cloudscale/list_sizes.json | 2 + libcloud/test/compute/test_cloudscale.py | 122 +++++++++++ libcloud/test/secrets.py-dist | 3 +- 9 files changed, 436 insertions(+), 1 deletion(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/compute/drivers/cloudscale.py ---------------------------------------------------------------------- diff --git a/libcloud/compute/drivers/cloudscale.py b/libcloud/compute/drivers/cloudscale.py new file mode 100644 index 0000000..a439b12 --- /dev/null +++ b/libcloud/compute/drivers/cloudscale.py @@ -0,0 +1,211 @@ +# 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. +# The ASF 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. +""" +A driver for cloudscale.ch. +""" + +import json + +from libcloud.utils.py3 import httplib + +from libcloud.common.base import ConnectionKey, JsonResponse +from libcloud.compute.types import Provider, NodeState +from libcloud.common.types import InvalidCredsError +from libcloud.compute.base import NodeDriver +from libcloud.compute.base import Node, NodeImage, NodeSize + + +class CloudscaleResponse(JsonResponse): + valid_response_codes = [httplib.OK, httplib.ACCEPTED, httplib.CREATED, + httplib.NO_CONTENT] + + def parse_error(self): + body = self.parse_body() + if self.status == httplib.UNAUTHORIZED: + raise InvalidCredsError(body['detail']) + else: + # We are taking the first issue here. There might be multiple ones, + # but that doesn't really matter. It's nicer if the error is just + # one error (because it's a Python API and there's only one + # exception. + return next(iter(body.values())) + + def success(self): + return self.status in self.valid_response_codes + + +class CloudscaleConnection(ConnectionKey): + """ + Connection class for the Vultr driver. + """ + host = 'api.cloudscale.ch' + responseCls = CloudscaleResponse + + def add_default_headers(self, headers): + """ + Add headers that are necessary for every request + + This method adds ``token`` to the request. + """ + headers['Authorization'] = 'Bearer %s' % (self.key) + headers['Content-Type'] = 'application/json' + return headers + + +class CloudscaleNodeDriver(NodeDriver): + """ + Cloudscale's node driver. + """ + + connectionCls = CloudscaleConnection + + type = Provider.CLOUDSCALE + name = 'Cloudscale' + website = 'https://www.cloudscale.ch' + + NODE_STATE_MAP = dict( + changing=NodeState.PENDING, + running=NodeState.RUNNING, + stopped=NodeState.STOPPED, + paused=NodeState.PAUSED, + ) + + def __init__(self, key, **kwargs): + super(CloudscaleNodeDriver, self).__init__(key, **kwargs) + + def list_nodes(self): + return self._list_resources('/v1/servers', self._to_node) + + def list_sizes(self): + return self._list_resources('/v1/flavors', self._to_size) + + def list_images(self): + return self._list_resources('/v1/images', self._to_image) + + def create_node(self, name, size, image, location=None, ex_create_attr={}): + """ + Create a node. + + The `ex_create_attr` parameter can include the following dictionary + key and value pairs: + + * `ssh_keys`: ``list`` of ``str`` ssh public keys + * `volume_size_gb`: ``int`` defaults to 10. + * `bulk_volume_size_gb`: defaults to None. + * `use_public_network`: ``bool`` defaults to True + * `use_private_network`: ``bool`` defaults to False + * `use_ipv6`: ``bool`` defaults to True + * `anti_affinity_with`: ``uuid`` of a server to create an anti-affinity + group with that server or add it to the same group as that server. + * `user_data`: ``str`` for optional cloud-config data + + :keyword ex_create_attr: A dictionary of optional attributes for + droplet creation + :type ex_create_attr: ``dict`` + + :keyword ex_user_data: User data to be added to the node on create. + (optional) + :type ex_user_data: ``str`` + + :return: The newly created node. + :rtype: :class:`Node` + """ + attr = dict(ex_create_attr) + attr.update( + name=name, + image=image.id, + flavor=size.id, + ) + result = self.connection.request( + '/v1/servers', + data=json.dumps(attr), + method='POST' + ) + return self._to_node(result.object) + + def reboot_node(self, node): + return self._action(node, 'reboot') + + def ex_start_node(self, node): + return self._action(node, 'start') + + def ex_stop_node(self, node): + return self._action(node, 'stop') + + def ex_node_by_uuid(self, uuid): + res = self.connection.request(self._get_server_url(uuid)) + return self._to_node(res.object) + + def destroy_node(self, node): + res = self.connection.request(self._get_server_url(node.id), method='DELETE') + return res.status == httplib.NO_CONTENT + + def _get_server_url(self, uuid): + return '/v1/servers/%s' % uuid + + def _action(self, node, action_name): + response = self.connection.request( + self._get_server_url(node.id) + '/' + action_name, + method='POST' + ) + return response.status == httplib.OK + + def _list_resources(self, url, tranform_func): + data = self.connection.request(url, method='GET').object + return [tranform_func(obj) for obj in data] + + def _to_node(self, data): + state = self.NODE_STATE_MAP.get(data['status'], NodeState.UNKNOWN) + extra_keys = ['volumes', 'inferfaces', 'anti_affinity_with'] + extra = {} + for key in extra_keys: + if key in data: + extra[key] = data[key] + + public_ips = [] + private_ips = [] + for interface in data['interfaces']: + if interface['type'] == 'public': + ips = public_ips + else: + ips = private_ips + for address_obj in interface['addresses']: + ips.append(address_obj['address']) + + return Node( + id=data['uuid'], + name=data['name'], + state=state, + public_ips=public_ips, + private_ips=private_ips, + extra=extra, + driver=self, + image=self._to_image(data['image']), + size=self._to_size(data['flavor']), + ) + + def _to_size(self, data): + extra = {'vcpu_count': data['vcpu_count']} + ram = data['memory_gb'] * 1024 + + return NodeSize(id=data['slug'], name=data['name'], + ram=ram, disk=10, + bandwidth=0, price=0, + extra=extra, driver=self) + + def _to_image(self, data): + extra = {'operating_system': data['operating_system']} + return NodeImage(id=data['slug'], name=data['name'], extra=extra, + driver=self) http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/compute/providers.py ---------------------------------------------------------------------- diff --git a/libcloud/compute/providers.py b/libcloud/compute/providers.py index ccdebc8..2d5127e 100644 --- a/libcloud/compute/providers.py +++ b/libcloud/compute/providers.py @@ -141,6 +141,8 @@ DRIVERS = { ('libcloud.compute.drivers.ntta', 'NTTAmericaNodeDriver'), Provider.ALIYUN_ECS: ('libcloud.compute.drivers.ecs', 'ECSDriver'), + Provider.CLOUDSCALE: + ('libcloud.compute.drivers.cloudscale', 'CloudscaleNodeDriver'), } http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/compute/types.py ---------------------------------------------------------------------- diff --git a/libcloud/compute/types.py b/libcloud/compute/types.py index 3b63b9e..658907e 100644 --- a/libcloud/compute/types.py +++ b/libcloud/compute/types.py @@ -70,6 +70,7 @@ class Provider(Type): :cvar AZURE_ARM: Azure Resource Manager (modern) driver. :cvar BLUEBOX: Bluebox :cvar CLOUDSIGMA: CloudSigma + :cvar CLOUDSCALE: cloudscale.ch :cvar CLOUDSTACK: CloudStack :cvar DIMENSIONDATA: Dimension Data Cloud :cvar EC2: Amazon AWS. @@ -115,6 +116,7 @@ class Provider(Type): CISCOCCS = 'ciscoccs' CLOUDFRAMES = 'cloudframes' CLOUDSIGMA = 'cloudsigma' + CLOUDSCALE = 'cloudscale' CLOUDSTACK = 'cloudstack' CLOUDWATT = 'cloudwatt' DIGITAL_OCEAN = 'digitalocean' http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/test/compute/fixtures/cloudscale/create_node.json ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/cloudscale/create_node.json b/libcloud/test/compute/fixtures/cloudscale/create_node.json new file mode 100644 index 0000000..a40e80c --- /dev/null +++ b/libcloud/test/compute/fixtures/cloudscale/create_node.json @@ -0,0 +1,46 @@ +{ + "href": "https://api.cloudscale.ch/v1/servers/47cec963-fcd2-482f-bdb6-24461b2d47b1", + "uuid": "47cec963-fcd2-482f-bdb6-24461b2d47b1", + "name": "db-master", + "status": "changing", + "flavor": { + "slug": "flex-4", + "name": "Flex-4", + "vcpu_count": 2, + "memory_gb": 4 + }, + "image": { + "slug": "debian-8", + "name": "Debian 8 (2016-10-20)", + "operating_system": "Debian" + }, + "volumes": [ + { + "type": "ssd", + "device_path": "", + "size_gb": 50 + } + ], + "interfaces": [ + { + "type": "public", + "addresses": [ + { + "version": 4, + "address": "185.98.122.176", + "prefix_length": 24, + "gateway": "185.98.122.1", + "reverse_ptr": "185-98-122-176.cust.cloudscale.ch" + }, + { + "version": 6, + "address": "2a06:c01:1:1902::7ab0:176", + "prefix_length": 64, + "gateway": "fe80::1", + "reverse_ptr": "185-98-122-176.cust.cloudscale.ch" + } + ] + } + ], + "anti_affinity_with": [] +} http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/test/compute/fixtures/cloudscale/list_images.json ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/cloudscale/list_images.json b/libcloud/test/compute/fixtures/cloudscale/list_images.json new file mode 100644 index 0000000..e8ed55c --- /dev/null +++ b/libcloud/test/compute/fixtures/cloudscale/list_images.json @@ -0,0 +1 @@ +[{"slug":"ubuntu-16.10","name":"Ubuntu 16.10 (2016-10-17)","operating_system": "Ubuntu"}] http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/test/compute/fixtures/cloudscale/list_nodes.json ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/cloudscale/list_nodes.json b/libcloud/test/compute/fixtures/cloudscale/list_nodes.json new file mode 100644 index 0000000..95f861c --- /dev/null +++ b/libcloud/test/compute/fixtures/cloudscale/list_nodes.json @@ -0,0 +1,48 @@ +[ + { + "href": "https://api.cloudscale.ch/v1/servers/47cec963-fcd2-482f-bdb6-24461b2d47b1", + "uuid": "47cec963-fcd2-482f-bdb6-24461b2d47b1", + "name": "db-master", + "status": "running", + "flavor": { + "slug": "flex-4", + "name": "Flex-4", + "vcpu_count": 2, + "memory_gb": 4 + }, + "image": { + "slug": "debian-8", + "name": "Debian 8 (2016-10-20)", + "operating_system": "Debian" + }, + "volumes": [ + { + "type": "ssd", + "device_path": "/dev/vda", + "size_gb": 50 + } + ], + "interfaces": [ + { + "type": "public", + "addresses": [ + { + "version": 4, + "address": "185.98.122.176", + "prefix_length": 24, + "gateway": "185.98.122.1", + "reverse_ptr": "185-98-122-176.cust.cloudscale.ch" + }, + { + "version": 6, + "address": "2a06:c01:1:1902::7ab0:176", + "prefix_length": 64, + "gateway": "fe80::1", + "reverse_ptr": "185-98-122-176.cust.cloudscale.ch" + } + ] + } + ], + "anti_affinity_with": [] + } +] http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/test/compute/fixtures/cloudscale/list_sizes.json ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/cloudscale/list_sizes.json b/libcloud/test/compute/fixtures/cloudscale/list_sizes.json new file mode 100644 index 0000000..8952a5e --- /dev/null +++ b/libcloud/test/compute/fixtures/cloudscale/list_sizes.json @@ -0,0 +1,2 @@ +[{"slug":"flex-2","name":"Flex-2","vcpu_count":1,"memory_gb":2}, + {"slug":"flex-4","name":"Flex-4","vcpu_count":2,"memory_gb": 4}] http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/test/compute/test_cloudscale.py ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/test_cloudscale.py b/libcloud/test/compute/test_cloudscale.py new file mode 100644 index 0000000..fc1cabb --- /dev/null +++ b/libcloud/test/compute/test_cloudscale.py @@ -0,0 +1,122 @@ +# 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. +# The ASF 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 + +try: + import simplejson as json +except ImportError: + import json # NOQA + +from libcloud.utils.py3 import httplib + +from libcloud.compute.drivers.cloudscale import CloudscaleNodeDriver + +from libcloud.test import LibcloudTestCase, MockHttpTestCase +from libcloud.test.file_fixtures import ComputeFileFixtures +from libcloud.test.secrets import CLOUDSCALE_PARAMS + + +class CloudscaleTests(LibcloudTestCase): + + def setUp(self): + CloudscaleNodeDriver.connectionCls.conn_classes = \ + (None, CloudscaleMockHttp) + self.driver = CloudscaleNodeDriver(*CLOUDSCALE_PARAMS) + + def test_list_images_success(self): + images = self.driver.list_images() + image, = images + self.assertTrue(image.id is not None) + self.assertTrue(image.name is not None) + + def test_list_sizes_success(self): + sizes = self.driver.list_sizes() + self.assertEqual(len(sizes), 2) + + size = sizes[0] + self.assertTrue(size.id is not None) + self.assertEqual(size.name, 'Flex-2') + self.assertEqual(size.ram, 2048) + + size = sizes[1] + self.assertTrue(size.id is not None) + self.assertEqual(size.name, 'Flex-4') + self.assertEqual(size.ram, 4096) + + def test_list_locations_not_existing(self): + # assertRaises doesn't exist in Python 2.5?! + try: + self.driver.list_locations() + except NotImplementedError: + pass + else: + assert False, 'Did not raise the wished error.' + + def test_list_nodes_success(self): + nodes = self.driver.list_nodes() + self.assertEqual(len(nodes), 1) + self.assertEqual(nodes[0].id, '47cec963-fcd2-482f-bdb6-24461b2d47b1') + self.assertEqual( + nodes[0].public_ips, + ['185.98.122.176', '2a06:c01:1:1902::7ab0:176'] + ) + + def test_reboot_node_success(self): + node = self.driver.list_nodes()[0] + result = self.driver.reboot_node(node) + self.assertTrue(result) + + def test_create_node_success(self): + test_size = self.driver.list_sizes()[0] + test_image = self.driver.list_images()[0] + created_node = self.driver.create_node('node-name', test_size, test_image) + self.assertEqual(created_node.id, "47cec963-fcd2-482f-bdb6-24461b2d47b1") + + def test_destroy_node_success(self): + node = self.driver.list_nodes()[0] + result = self.driver.destroy_node(node) + self.assertTrue(result) + + +class CloudscaleMockHttp(MockHttpTestCase): + fixtures = ComputeFileFixtures('cloudscale') + + def _v1_images(self, method, url, body, headers): + body = self.fixtures.load('list_images.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _v1_flavors(self, method, url, body, headers): + body = self.fixtures.load('list_sizes.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _v1_servers(self, method, url, body, headers): + if method == 'GET': + body = self.fixtures.load('list_nodes.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + else: + body = self.fixtures.load('create_node.json') + response = httplib.responses[httplib.CREATED] + return (httplib.CREATED, body, {}, response) + + def _v1_servers_47cec963_fcd2_482f_bdb6_24461b2d47b1(self, method, url, body, headers): + assert method == 'DELETE' + return (httplib.NO_CONTENT, "", {}, httplib.responses[httplib.NO_CONTENT]) + + def _v1_servers_47cec963_fcd2_482f_bdb6_24461b2d47b1_reboot(self, method, url, body, headers): + return (httplib.OK, "", {}, httplib.responses[httplib.OK]) + +if __name__ == '__main__': + sys.exit(unittest.main()) http://git-wip-us.apache.org/repos/asf/libcloud/blob/c6ad7015/libcloud/test/secrets.py-dist ---------------------------------------------------------------------- diff --git a/libcloud/test/secrets.py-dist b/libcloud/test/secrets.py-dist index 8c73f54..2c21f84 100644 --- a/libcloud/test/secrets.py-dist +++ b/libcloud/test/secrets.py-dist @@ -51,6 +51,7 @@ PROFIT_BRICKS_PARAMS = ('user', 'key') VULTR_PARAMS = ('key') PACKET_PARAMS = ('api_key') ECS_PARAMS = ('access_key', 'access_secret') +CLOUDSCALE_PARAMS = ('token',) # Storage STORAGE_S3_PARAMS = ('key', 'secret') @@ -94,4 +95,4 @@ DNS_PARAMS_DNSPOD = ('key', ) CONTAINER_PARAMS_DOCKER = ('user', 'password') CONTAINER_PARAMS_ECS = ('user', 'password', 'region') CONTAINER_PARAMS_KUBERNETES = ('user', 'password') -CONTAINER_PARAMS_RANCHER = ('user', 'password') \ No newline at end of file +CONTAINER_PARAMS_RANCHER = ('user', 'password')