From fd5bb8c7f7ddf18283264f19cd4c375f60a63d68 Mon Sep 17 00:00:00 2001 From: Vincent Viallet Date: Fri, 14 Jun 2013 11:56:01 +0800 Subject: [PATCH 1/5] Add digital-ocean cloud support. --- library/cloud/do | 317 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 library/cloud/do diff --git a/library/cloud/do b/library/cloud/do new file mode 100644 index 00000000000..33d705bee97 --- /dev/null +++ b/library/cloud/do @@ -0,0 +1,317 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +DOCUMENTATION = ''' +--- +module: do +short_description: Create/delete a droplet/SSH_key in DigitalOcean +description: + - Create/delete a droplet in DigitalOcean and optionally waits for it to be 'running', or deploy an SSH key. +version_added: "1.2" +options: + command: + description: + - Which target you want to operate on. + required: true + choices: ['droplet', 'ssh'] + state: + description: + - Indicate desired state of the target. + required: true + choices: ['present', 'active', 'absent', 'deleted'] + client_id: description: + - Digital Ocean manager id. + api_key: + description: + - Digital Ocean api key. + id: + description: + - Numeric, the droplet id you want to operate on. + name: + description: + - String, this is the name of the droplet - must be formatted by hostname rules, or the name of a SSH key. + size_id: + description: + - Numeric, this is the id of the size you would like the droplet created at. + image_id: + description: + - Numeric, this is the id of the image you would like the droplet created with. + region_id: + description: + - Numeric, this is the id of the region you would like your server in IE: US/Amsterdam. + ssh_key_ids: + description: + - Optional, comma separated list of ssh_key_ids that you would like to be added to the server + wait: + description: + - Wait for the droplet to be in state 'running' before returning. + default: "yes" + choices: [ "yes", "no" ] + wait_timeout: + description: + - How long before wait gives up, in seconds. + default: 300 + ssh_pub_key: + description: + - The public SSH key you want to add to your account. + +notes: + - Two environment variables can be used, DO_CLIENT_ID and DO_API_KEY. +''' + + +EXAMPLES = ''' +# a playbook task line: +tasks: + - do: state=present client_id=XXX api_key=XXX id=33 + +# /usr/bin/ansible invocations +ansible -i host -m do -a "state=present client_id=XXX api_key=XXX id=3" +''' + +import sys +import os +import time + +try: + from dopy.manager import DoError, DoManager +except ImportError as e: + print "failed=True msg='dopy required for this module'" + sys.exit(1) + +class TimeoutError(DoError): + def __init__(self, msg, id): + super(TimeoutError, self).__init__(msg) + self.id = id + +class JsonfyMixIn(object): + def to_json(self): + return self.__dict__ + +class Droplet(JsonfyMixIn): + manager = None + + def __init__(self, droplet_json): + self.status = 'new' + self.__dict__.update(droplet_json) + + def is_powered_on(self): + return self.status == 'active' + + def update_attr(self, attrs=None, times=5): + if attrs: + for k, v in attrs.iteritems(): + setattr(self, k, v) + else: + json = self.manager.show_droplet(self.id) + if not json['ip_address']: + if times > 0: + time.sleep(2) + self.update_attr(times=times-1) + else: + raise TimeoutError('No ip is found.', self.id) + else: + self.update_attr(json) + + def power_on(self): + assert self.status == 'off', 'Can only power on a closed one.' + json = self.manager.power_on_droplet(self.id) + self.update_attr(json) + + def ensure_powered_on(self, wait=True, wait_timeout=300): + if self.is_powered_on(): + return + if self.status == 'off': # powered off + self.power_on() + + if wait: + end_time = time.time()+wait_timeout + while time.time() < end_time: + time.sleep(min(20, end_time-time.time())) + self.update_attr() + if self.is_powered_on(): + return + raise TimeoutError('Wait for droplet running timeout', self.id) + + def destroy(self): + return self.manager.destroy_droplet(self.id) + + @classmethod + def setup(cls, client_id, api_key): + cls.manager = DoManager(client_id, api_key) + + @classmethod + def add(cls, name, size_id, image_id, region_id, ssh_key_ids=None): + json = cls.manager.new_droplet(name, size_id, image_id, region_id, ssh_key_ids) + droplet = cls(json) + droplet.update_attr() + return droplet + + @classmethod + def find(cls, id): + if not id: + return False + droplets = cls.list_all() + for droplet in droplets: + if droplet.id == id: + return droplet + return False + + @classmethod + def list_all(cls): + json = cls.manager.all_active_droplets() + return map(cls, json) + +class SSH(JsonfyMixIn): + manager = None + + def __init__(self, ssh_key_json): + self.__dict__.update(ssh_key_json) + update_attr = __init__ + + def destroy(self): + self.manager.destroy_ssh_key(self.id) + return True + + @classmethod + def setup(cls, client_id, api_key): + cls.manager = DoManager(client_id, api_key) + + @classmethod + def find(cls, name): + if not name: + return False + keys = cls.list_all() + for key in keys: + if key.name == name: + return key + return False + + @classmethod + def list_all(cls): + json = cls.manager.all_ssh_keys() + return map(cls, json) + + @classmethod + def add(cls, name, key_pub): + json = cls.manager.new_ssh_key(name, key_pub) + return cls(json) + +def core(module): + def getkeyordie(k): + v = module.params[k] + if v is None: + module.fail_json(msg='Unable to load %s' % k) + return v + + try: + # params['client_id'] will be None even if client_id is not passed in + client_id = module.params['client_id'] or os.environ['DO_CLIENT_ID'] + api_key = module.params['api_key'] or os.environ['DO_API_KEY'] + except KeyError, e: + module.fail_json(msg='Unable to load %s' % e.message) + + changed = True + command = module.params['command'] + state = module.params['state'] + + if command == 'droplet': + Droplet.setup(client_id, api_key) + if state in ('active', 'present'): + droplet = Droplet.find(module.params['id']) + if not droplet: + droplet = Droplet.add( + name=getkeyordie('name'), + size_id=getkeyordie('size_id'), + image_id=getkeyordie('image_id'), + region_id=getkeyordie('region_id'), + ssh_key_ids=module.params['ssh_key_ids'] + ) + if droplet.is_powered_on(): + changed = False + droplet.ensure_powered_on( + wait=getkeyordie('wait'), + wait_timeout=getkeyordie('wait_timeout') + ) + module.exit_json(changed=changed, droplet=droplet.to_json()) + + elif state in ('absent', 'deleted'): + droplet = Droplet.find(getkeyordie('id')) + if not droplet: + module.exit_json(changed=False, msg='The droplet is not found.') + event_json = droplet.destroy() + module.exit_json(changed=True, event_id=event_json['event_id']) + + elif command == 'ssh': + SSH.setup(client_id, api_key) + name = getkeyordie('name') + if state in ('active', 'present'): + key = SSH.find(name) + if key: + module.exit_json(changed=False, msg='SSH key with the name of %s already exists.' % name) + key = SSH.add(name, getkeyordie('ssh_pub_key')) + module.exit_json(changed=True, ssh_key=key.to_json()) + + elif state in ('absent', 'deleted'): + key = SSH.find(name) + if not key: + module.exit_json(changed=False, msg='SSH key with the name of %s is not found.' % name) + key.destroy() + module.exit_json(changed=True) + + +def main(): + module = AnsibleModule( + argument_spec = dict( + command = dict(required=True, choices=['droplet', 'ssh']), + state = dict(required=True, choices=['active', 'present', 'absent', 'deleted']), + client_id = dict(aliases=['CLIENT_ID'], no_log=True), + api_key = dict(aliases=['API_KEY'], no_log=True), + name = dict(type='str'), + size_id = dict(type='int'), + image_id = dict(type='int'), + region_id = dict(type='int'), + ssh_key_ids = dict(default=''), + id = dict(aliases=['droplet_id'], type='int'), + wait = dict(type='bool', choices=BOOLEANS, default='yes'), + wait_timeout = dict(default=300, type='int'), + ssh_pub_key = dict(type='str'), + ), + required_together = ( + ['size_id', 'image_id', 'region_id'], + ), + mutually_exclusive = ( + ['size_id', 'ssh_pub_key'], + ['image_id', 'ssh_pub_key'], + ['region_id', 'ssh_pub_key'], + ), + required_one_of = ( + ['id', 'name'], + ), + ) + + try: + core(module) + except TimeoutError as e: + module.fail_json(msg=str(e), id=e.id) + except (DoError, Exception) as e: + module.fail_json(msg=str(e)) + +# this is magic, see lib/ansible/module_common.py +#<> + +main() From f9e3480d12ccc9f6f6f84017efe2242809f4d793 Mon Sep 17 00:00:00 2001 From: Vincent Viallet Date: Fri, 14 Jun 2013 14:59:52 +0800 Subject: [PATCH 2/5] Ensure an existing ssh-key returns useful information (id + name) instead of a string; this way it can be used to register a variable to use in a later task. --- library/cloud/do | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/cloud/do b/library/cloud/do index 33d705bee97..faec19eb02c 100644 --- a/library/cloud/do +++ b/library/cloud/do @@ -262,7 +262,7 @@ def core(module): if state in ('active', 'present'): key = SSH.find(name) if key: - module.exit_json(changed=False, msg='SSH key with the name of %s already exists.' % name) + module.exit_json(changed=False, ssh_key=key.to_json()) key = SSH.add(name, getkeyordie('ssh_pub_key')) module.exit_json(changed=True, ssh_key=key.to_json()) From 581dea70d15483b56e174d8debd13a5af701692e Mon Sep 17 00:00:00 2001 From: "martin f. krafft" Date: Fri, 14 Jun 2013 10:31:01 +0200 Subject: [PATCH 3/5] Generalise determination of hacking directory path Bash needs a special case to determine the dirname of the sourced path (the hacking dir), but in all other cases, using $0 allows the script to be sourced also from within the hacking directory, not only from its parent. Signed-off-by: martin f. krafft --- hacking/env-setup | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/hacking/env-setup b/hacking/env-setup index 338e768686b..8eb45370ab1 100755 --- a/hacking/env-setup +++ b/hacking/env-setup @@ -3,11 +3,12 @@ # modifies environment for running Ansible from checkout # When run using source as directed, $0 gets set to bash, so we must use $BASH_SOURCE -if [ -n "$BASH_SOURCE" ] ; then - HACKING_DIR=`dirname $BASH_SOURCE` -else - HACKING_DIR="$PWD/hacking" -fi +case "$0" in + (bash) + HACKING_DIR=${BASH_SOURCE%/*};; + (*) + HACKING_DIR=${0%/*};; +esac # The below is an alternative to readlink -fn which doesn't exist on OS X # Source: http://stackoverflow.com/a/1678636 FULL_PATH=`python -c "import os; print(os.path.realpath('$HACKING_DIR'))"` From 3b008d6fa6f8d29262b846276cd68a1c4621d94b Mon Sep 17 00:00:00 2001 From: "martin f. krafft" Date: Fri, 14 Jun 2013 10:34:07 +0200 Subject: [PATCH 4/5] Expand usage synopsis 'source' is actually a "bashism" and the POSIX-way of sourcing a file uses the single dot (which is arguably less readable). Both yield the same result, and since the script may now also be sourced from within the hacking directory, this commit expands the usage synopsis accordingly. Signed-off-by: martin f. krafft Conflicts: hacking/env-setup --- hacking/env-setup | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hacking/env-setup b/hacking/env-setup index 8eb45370ab1..83f57e9cee5 100755 --- a/hacking/env-setup +++ b/hacking/env-setup @@ -1,5 +1,8 @@ #!/bin/bash -# usage: source ./hacking/env-setup [-q] +# usage: source env-setup [-q] +# source hacking/env-setup [-q] +# . ./env-setup [-q] +# . ./hacking/env-setup [q] # modifies environment for running Ansible from checkout # When run using source as directed, $0 gets set to bash, so we must use $BASH_SOURCE From d4b5122ad9c97de849013e23df1213d1d3f505c7 Mon Sep 17 00:00:00 2001 From: Vincent Viallet Date: Tue, 18 Jun 2013 10:45:45 +0800 Subject: [PATCH 5/5] Rename digital ocean module to digital_ocean, add documentation. --- library/cloud/{do => digital_ocean} | 34 ++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) rename library/cloud/{do => digital_ocean} (87%) diff --git a/library/cloud/do b/library/cloud/digital_ocean similarity index 87% rename from library/cloud/do rename to library/cloud/digital_ocean index faec19eb02c..4cbaf708459 100644 --- a/library/cloud/do +++ b/library/cloud/digital_ocean @@ -17,7 +17,7 @@ # along with Ansible. If not, see . DOCUMENTATION = ''' --- -module: do +module: digital_ocean short_description: Create/delete a droplet/SSH_key in DigitalOcean description: - Create/delete a droplet in DigitalOcean and optionally waits for it to be 'running', or deploy an SSH key. @@ -75,12 +75,30 @@ notes: EXAMPLES = ''' -# a playbook task line: -tasks: - - do: state=present client_id=XXX api_key=XXX id=33 +# Ensure a SSH key is present +- digital_ocean: state=present command=ssh name=my_ssh_key ssh_pub_key='ssh-rsa AAAA...' client_id=XXX api_key=XXX -# /usr/bin/ansible invocations -ansible -i host -m do -a "state=present client_id=XXX api_key=XXX id=3" +If a key matches this name, will return the ssh key id and changed = False +If no existing key matches this name, a new key is created, the ssh key id is returned and changed = False + +# Create a new Droplet +- digital_ocean: state=present command=droplet name=my_new_droplet client_id=XXX api_key=XXX size_id=1 region_id=2 image_id=3 wait_timeout=500 + +Will return the droplet details including the droplet id (used for idempotence) + +# Ensure a droplet is present +- digital_ocean: state=present command=droplet id=123 name=my_new_droplet client_id=XXX api_key=XXX size_id=1 region_id=2 image_id=3 wait_timeout=500 + +If droplet id already exist, will return the droplet details and changed = False +If no droplet matches the id, a new droplet will be created and the droplet details (including the new id) are returned, changed = True. + +# Create a droplet with ssh key +- digital_ocean: state=present ssh_key_ids=id name=my_new_droplet client_id=XXX api_key=XXX size_id=1 region_id=2 image_id=3 + +The ssh key id can be passed as argument at the creation of a droplet (see ssh_key_ids). +Several keys can be added to ssh_key_ids as id1,id2,id3 + +The keys are used to connect as root to the droplet. ''' import sys @@ -277,8 +295,8 @@ def core(module): def main(): module = AnsibleModule( argument_spec = dict( - command = dict(required=True, choices=['droplet', 'ssh']), - state = dict(required=True, choices=['active', 'present', 'absent', 'deleted']), + command = dict(required=True, choices=['droplet', 'ssh'], default='droplet'), + state = dict(required=True, choices=['active', 'present', 'absent', 'deleted'], default='present'), client_id = dict(aliases=['CLIENT_ID'], no_log=True), api_key = dict(aliases=['API_KEY'], no_log=True), name = dict(type='str'),