-
Notifications
You must be signed in to change notification settings - Fork 925
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
Changes from all commits
c6ad701
588cb0c
fd9fefd
d1b7d78
8f11620
5bf298f
75300c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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) |
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, | ||
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())) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wah, ok what's this about? This is unusual! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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={}): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think about accepting the arguments in There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have documented in |
||
""" | ||
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
There was a problem hiding this comment.
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
?There was a problem hiding this comment.
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 inJsonResponse
orResponse
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
correct, never mind https://github.com/apache/libcloud/blob/trunk/libcloud/common/base.py#L216