Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Driver Implementation for the cloudscale.ch API #951

Merged
merged 7 commits into from
Dec 2, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/_static/images/provider_logos/cloudscale.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/compute/_supported_methods_main.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Provider list nodes create node reboot node destroy
`Bluebox Blocks`_ yes yes yes yes yes yes yes
`Brightbox`_ yes yes no yes yes yes no
`BSNL`_ yes yes yes yes yes yes yes
`cloudscale.ch`_ yes yes yes yes yes yes no
`CloudSigma (API v2.0)`_ yes yes no yes yes yes no
`CloudStack`_ yes yes yes yes yes yes yes
`Cloudwatt`_ yes yes yes yes yes yes yes
Expand Down Expand Up @@ -66,6 +67,7 @@ Provider list nodes create node reboot node destroy
.. _`Bluebox Blocks`: http://bluebox.net
.. _`Brightbox`: http://www.brightbox.co.uk/
.. _`BSNL`: http://www.bsnlcloud.com/
.. _`cloudscale.ch`: https://www.cloudscale.ch/
.. _`CloudSigma (API v2.0)`: http://www.cloudsigma.com/
.. _`CloudStack`: http://cloudstack.org/
.. _`Cloudwatt`: https://www.cloudwatt.com/
Expand Down
2 changes: 2 additions & 0 deletions docs/compute/_supported_providers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Provider Documentation
`Bluebox Blocks`_ BLUEBOX single region driver :mod:`libcloud.compute.drivers.bluebox` :class:`BlueboxNodeDriver`
`Brightbox`_ BRIGHTBOX single region driver :mod:`libcloud.compute.drivers.brightbox` :class:`BrightboxNodeDriver`
`BSNL`_ :doc:`Click </compute/drivers/bsnl>` BSNL single region driver :mod:`libcloud.compute.drivers.bsnl` :class:`BSNLNodeDriver`
`cloudscale.ch`_ :doc:`Click </compute/drivers/cloudscale` CLOUDSCALE single region driver :mod:`libcloud.compute.drivers.cloudscale` :class:`CloudscaleNodeDriver`
`CloudSigma (API v2.0)`_ :doc:`Click </compute/drivers/cloudsigma>` CLOUDSIGMA single region driver :mod:`libcloud.compute.drivers.cloudsigma` :class:`CloudSigmaNodeDriver`
`CloudStack`_ :doc:`Click </compute/drivers/cloudstack>` CLOUDSTACK single region driver :mod:`libcloud.compute.drivers.cloudstack` :class:`CloudStackNodeDriver`
`Cloudwatt`_ :doc:`Click </compute/drivers/cloudwatt>` CLOUDWATT single region driver :mod:`libcloud.compute.drivers.cloudwatt` :class:`CloudwattNodeDriver`
Expand Down Expand Up @@ -66,6 +67,7 @@ Provider Documentation
.. _`Bluebox Blocks`: http://bluebox.net
.. _`Brightbox`: http://www.brightbox.co.uk/
.. _`BSNL`: http://www.bsnlcloud.com/
.. _`cloudscale.ch`: https://www.cloudscale.ch/
.. _`CloudSigma (API v2.0)`: http://www.cloudsigma.com/
.. _`CloudStack`: http://cloudstack.org/
.. _`Cloudwatt`: https://www.cloudwatt.com/
Expand Down
75 changes: 75 additions & 0 deletions docs/compute/drivers/cloudscale.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
Cloudscale Compute Driver Documentation
=======================================

`cloudscale.ch`_ is a public cloud provider based in Switzerland.

.. figure:: /_static/images/provider_logos/cloudscale.png
:align: center
:width: 200
:target: http://www.cloudscale.ch


How to get an API Key
---------------------

Simply visit `<https://control.cloudscale.ch/user/api-tokens>`_ and generate
your key.

You can generate read and read/write API keys. These token types give you more
access control. Revoking an API token is also possible.

Using the API to the full extent
--------------------------------

Most of the `cloudscale.ch` API is covered by the simple commands:

- ``driver.list_sizes()``
- ``driver.list_images()``
- ``driver.list_nodes()``
- ``driver.reboot_node(node)``
- ``driver.ex_start_node(node)``
- ``driver.ex_stop_node(node)``
- ``driver.ex_node_by_uuid(server_uuid)``
- ``driver.destroy_node(node)``
- ``driver.create_node(name, size, image, ex_create_attr={})``

In our :ref:`example <cloudscale-examples>` below you can see how you use
``ex_create_attr`` when creating servers. Possible dictionary entries in
``ex_create_attr`` are:

- ``ssh_keys`` (``list`` of ``str``) - A list of SSH public keys.
- ``volume_size_gb`` (``int``) - The size in GB of the root volume.
- ``bulk_volume_size_gb`` (``int``) - The size in GB of the bulk storage volume.
- ``use_public_network`` (``bool``) - Attaching/Detaching the public network interface.
- ``use_private_network`` (``bool``) - Attaching/Detaching the private network interface.
- ``use_ipv6`` (``bool``) - Enabling/Disabling IPv6.
- ``anti_affinity_with`` (``str``) - Pass the UUID of another server.
- ``user_data`` (``str``) - Cloud-init configuration (cloud-config). Provide YAML.


There's more extensive documentation on these parameters in our
`Server-Create API Documentation`_.

.. _cloudscale-examples:

Examples
--------

Create a cloudscale.ch server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. literalinclude:: /examples/compute/cloudscale/cloudscale_compute_simple.py
:language: python

API Docs
--------

.. autoclass:: libcloud.compute.drivers.cloudscale.CloudscaleNodeDriver
:members: create_node, list_images, list_nodes, list_sizes,
wait_until_running, reboot_node, ex_start_node, ex_stop_node,
ex_node_by_uuid, destroy_node
:undoc-members:

.. _`cloudscale.ch`: https://www.cloudscale.ch
.. _`cloudscale.ch API`: https://www.cloudscale.ch/en/api/v1
.. _`Server-Create API Documentation`: https://www.cloudscale.ch/en/api/v1#servers-create
27 changes: 27 additions & 0 deletions docs/examples/compute/cloudscale/cloudscale_compute_simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from pprint import pprint

import libcloud

cls = libcloud.get_driver(
libcloud.DriverType.COMPUTE,
libcloud.DriverType.COMPUTE.CLOUDSCALE
)

TOKEN = '3pjzjh3h3rfynqa4iemvtvc33pyfzss2'
driver = cls(TOKEN)

sizes = driver.list_sizes()
images = driver.list_images()
pprint(sizes)
pprint(images)

new_node = driver.create_node(
name='hello-darkness-my-old-friend',
size=sizes[0],
image=images[0],
ex_create_attr=dict(
ssh_keys=['ssh-rsa AAAAB3Nza...'],
use_private_network=True,
)
)
pprint(new_node)
246 changes: 246 additions & 0 deletions libcloud/compute/drivers/cloudscale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
# 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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is overridden just because of the addition of httplib.NO_CONTENT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't see us overriding anything here, valid_response_codes does not exist in JsonResponse or Response.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wah, ok what's this about? This is unusual!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means "take the first/any element of the collection". There's no nicer way to express it if your collection is not a list.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it means that we change the base method then I'd be happier with that, you should have a bit more control over the meta in the exception. Assuming that the other errors in the response could provide context to the user about what they did wrong they shouldn't have to use a packet sniffer to get that data https://github.com/apache/libcloud/blob/trunk/libcloud/common/base.py#L178-L180
Change that to an instance method (self.exception_from_message), which calls the top level function.
Override that method in your own class to raise a new exception class, inheriting from LibcloudException, adding a field for the list of errors. update __str__ with the first error, or a list joined with commas?

@davidhalter

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do this another time. 👍 merging


def success(self):
return self.status in self.valid_response_codes


class CloudscaleConnection(ConnectionKey):
"""
Connection class for the cloudscale.ch 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):
'''
List all your existing compute nodes.
'''
return self._list_resources('/v1/servers', self._to_node)

def list_sizes(self):
'''
Lists all available sizes. On cloudscale these are known as flavors.
'''
return self._list_resources('/v1/flavors', self._to_size)

def list_images(self):
'''
List all images.

Images are identified by slugs on cloudscale.ch. This means that minor
version upgrades (e.g. Ubuntu 16.04.1 to Ubuntu 16.04.2) will be
possible within the same id ``ubuntu-16.04``.
'''
return self._list_resources('/v1/images', self._to_image)

def create_node(self, name, size, image, location=None, ex_create_attr={}):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about accepting the arguments in ex_create_attr directly separate parameters instead? Compare with CloudStackNodeDriver.create_node(): https://github.com/apache/libcloud/blob/trunk/libcloud/compute/drivers/cloudstack.py#L1528

Copy link
Contributor Author

@davidhalter davidhalter Nov 23, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue that some other drivers (e.g. digitalocean) are doing it the same way. Prefixing everything with ex_ feels clunky and having a dictionary makes it easy to be "future-compatible".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok with this, as long as the attributes are documented somewhere. You could also name the main ones and just merge them into the dictionary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have documented in docs/compute/drivers/cloudscale.rst. Should I add it to the docstring?

"""
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``

: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):
'''
Reboot a node. It's also possible to use ``node.reboot()``.
'''
return self._action(node, 'reboot')

def ex_start_node(self, node):
'''
Start a node. This is only possible if the node is stopped.
'''
return self._action(node, 'start')

def ex_stop_node(self, node):
'''
Stop a specific node. Similar to ``shutdown -h now``. This is only
possible if the node is running.
'''
return self._action(node, 'stop')

def ex_node_by_uuid(self, uuid):
'''
:param str ex_user_data: A valid uuid that references your exisiting
cloudscale.ch server.
:type ex_user_data: ``str``

:return: The server node you asked for.
:rtype: :class:`Node`
'''
res = self.connection.request(self._get_server_url(uuid))
return self._to_node(res.object)

def destroy_node(self, node):
'''
Delete a node. It's also possible to use ``node.destroy()``.
This will irreversibly delete the cloudscale.ch server and all its
volumes. So please be cautious.
'''
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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice pattern 👍

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', 'interfaces', '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)
2 changes: 2 additions & 0 deletions libcloud/compute/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@
('libcloud.compute.drivers.ntta', 'NTTAmericaNodeDriver'),
Provider.ALIYUN_ECS:
('libcloud.compute.drivers.ecs', 'ECSDriver'),
Provider.CLOUDSCALE:
('libcloud.compute.drivers.cloudscale', 'CloudscaleNodeDriver'),
}


Expand Down
Loading