summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/ansible/modules/cloud/cloudscale/cloudscale_server.py445
-rw-r--r--test/integration/Makefile5
-rw-r--r--test/integration/cloudscale.yml7
-rw-r--r--test/integration/targets/cloudscale_server/aliases0
-rw-r--r--test/integration/targets/cloudscale_server/defaults/main.yml5
-rw-r--r--test/integration/targets/cloudscale_server/meta/main.yml2
-rw-r--r--test/integration/targets/cloudscale_server/tasks/main.yml138
7 files changed, 602 insertions, 0 deletions
diff --git a/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py b/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py
new file mode 100644
index 0000000000..7109c55a24
--- /dev/null
+++ b/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py
@@ -0,0 +1,445 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# (c) 2017, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+ANSIBLE_METADATA = {'status': ['preview'],
+ 'supported_by': 'community',
+ 'version': '1.0'}
+
+DOCUMENTATION = '''
+---
+module: cloudscale_server
+short_description: Manages servers on the cloudscale.ch IaaS service
+description:
+ - Create, start, stop and delete servers on the cloudscale.ch IaaS service.
+ - All operations are performed using the cloudscale.ch public API v1.
+ - "For details consult the full API documentation: U(https://www.cloudscale.ch/en/api/v1)."
+ - An valid API token is required for all operations. You can create as many tokens as you like using the cloudscale.ch control panel at U(https://control.cloudscale.ch).
+notes:
+ - Instead of the api_token parameter the CLOUDSCALE_API_TOKEN environment variable can be used.
+ - To create a new server at least the C(name), C(ssh_key), C(image) and C(flavor) options are required.
+ - If more than one server with the name given by the C(name) option exists, execution is aborted.
+ - Once a server is created all parameters except C(state) are read-only. You can't change the name, flavor or any other property. This is a limitation of the cloudscale.ch API. The module will silently ignore differences between the configured parameters and the running server if a server with the correct name or UUID exists. Only state changes will be applied.
+version_added: 2.3
+author: "Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch>"
+options:
+ state:
+ description:
+ - State of the server
+ required: False
+ default: running
+ choices: ['running', 'stopped', 'absent']
+ name:
+ description:
+ - Name of the Server
+ - Either C(name) or C(uuid) are required. These options are mutually exclusive.
+ required: False
+ uuid:
+ description:
+ - UUID of the server
+ - Either C(name) or C(uuid) are required. These options are mutually exclusive.
+ required: False
+ flavor:
+ description:
+ - Flavor of the server
+ required: False
+ image:
+ description:
+ - Image used to create the server
+ required: False
+ volume_size_gb:
+ description:
+ - Size of the root volume in GB
+ required: False
+ default: 10
+ bulk_volume_size_gb:
+ description:
+ - Size of the bulk storage volume in GB
+ required: False
+ default: null (no bulk storage volume)
+ ssh_keys:
+ description:
+ - List of SSH public keys
+ - Use the full content of your .pub file here.
+ required: False
+ use_public_network:
+ description:
+ - Attach a public network interface to the server
+ required: False
+ default: True
+ use_private_network:
+ description:
+ - Attach a private network interface to the server
+ required: False
+ default: False
+ use_ipv6:
+ description:
+ - Enable IPv6 on the public network interface
+ required: False
+ default: True
+ anti_affinity_with:
+ description:
+ - UUID of another server to create an anti-affinity group with
+ required: False
+ user_data:
+ description:
+ - Cloud-init configuration (cloud-config) data to use for the server.
+ required: False
+ api_token:
+ description:
+ - cloudscale.ch API token.
+ - This can also be passed in the CLOUDSCALE_API_TOKEN environment variable.
+ required: False
+'''
+
+EXAMPLES = '''
+# Start a server (if it does not exist) and register the server details
+- name: Start cloudscale.ch server
+ cloudscale_server:
+ name: my-shiny-cloudscale-server
+ image: debian-8
+ flavor: flex-4
+ ssh_keys: ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale
+ use_private_network: True
+ bulk_volume_size_gb: 100
+ api_token: xxxxxx
+ register: server1
+
+# Start another server in anti-affinity to the first one
+- name: Start second cloudscale.ch server
+ cloudscale_server:
+ name: my-other-shiny-server
+ image: ubuntu-16.04
+ flavor: flex-8
+ ssh_keys: ssh-rsa XXXXXXXXXXX ansible@cloudscale
+ anti_affinity_with: '{{ server1.uuid }}'
+ api_token: xxxxxx
+
+# Stop the first server
+- name: Stop my first server
+ cloudscale_server:
+ uuid: '{{ server1.uuid }}'
+ state: stopped
+ api_token: xxxxxx
+
+# Delete my second server
+- name: Delete my second server
+ cloudscale_server:
+ name: my-other-shiny-server
+ state: absent
+ api_token: xxxxxx
+'''
+
+RETURN = '''
+href:
+ description: API URL to get details about this server
+ returned: success when not state == absent
+ type: string
+ sample: https://api.cloudscale.ch/v1/servers/cfde831a-4e87-4a75-960f-89b0148aa2cc
+uuid:
+ description: The unique identifier for this server
+ returned: success
+ type: string
+ sample: cfde831a-4e87-4a75-960f-89b0148aa2cc
+name:
+ description: The display name of the server
+ returned: success
+ type: string
+ sample: its-a-me-mario.cloudscale.ch
+state:
+ description: The current status of the server
+ returned: success
+ type: string
+ sample: running
+flavor:
+ description: The flavor that has been used for this server
+ returned: success when not state == absent
+ type: string
+ sample: flex-8
+image:
+ description: The image used for booting this server
+ returned: success when not state == absent
+ type: string
+ sample: debian-8
+volumes:
+ description: List of volumes attached to the server
+ returned: success when not state == absent
+ type: list
+ sample: [ {"type": "ssd", "device": "/dev/vda", "size_gb": "50"} ]
+interfaces:
+ description: List of network ports attached to the server
+ returned: success when not state == absent
+ type: list
+ sample: [ { "type": "public", "addresses": [ ... ] } ]
+anti_affinity_with:
+ description: List of servers in the same anti-affinity group
+ returned: success when not state == absent
+ type: string
+ sample: []
+'''
+
+from datetime import datetime, timedelta
+import json
+import os
+from time import sleep
+from urllib import urlencode
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.urls import fetch_url
+
+API_URL = 'https://api.cloudscale.ch/v1/'
+TIMEOUT_WAIT = 30
+ALLOWED_STATES = ('running',
+ 'stopped',
+ 'absent',
+ )
+
+class AnsibleCloudscaleServer(object):
+
+ def __init__(self, module, api_token):
+ self._module = module
+ self._auth_header = {'Authorization': 'Bearer %s' % api_token}
+
+ # Check if server already exists and load properties
+ uuid = self._module.params['uuid']
+ name = self._module.params['name']
+
+ # Initialize server dictionary
+ self.info = {'uuid': uuid, 'name': name, 'state': 'absent'}
+
+ servers = self.list_servers()
+ matching_server = []
+ for s in servers:
+ if uuid:
+ # Look for server by UUID if given
+ if s['uuid'] == uuid:
+ self.info = self._transform_state(s)
+ break
+ else:
+ # Look for server by name
+ if s['name'] == name:
+ matching_server.append(s)
+ else:
+ if len(matching_server) == 1:
+ self.info = self._transform_state(matching_server[0])
+ elif len(matching_server) > 1:
+ self._module.fail_json(msg="More than one server with name '%s' exists. "
+ "Use the 'uuid' parameter to identify the server" % name)
+
+
+ def _get(self, api_call):
+ resp, info = fetch_url(self._module, API_URL+api_call, headers=self._auth_header)
+
+ if info['status'] == 200:
+ return json.loads(resp.read())
+ else:
+ self._module.fail_json(msg='Failure while calling the cloudscale.ch API with GET for '
+ '"%s": %s' % (api_call, info['body']))
+
+
+ def _post(self, api_call, data=None):
+ if data is not None:
+ data = urlencode(data)
+
+ resp, info = fetch_url(self._module,
+ API_URL+api_call,
+ headers = self._auth_header,
+ method='POST',
+ data=data)
+
+ if info['status'] == 201:
+ return json.loads(resp.read())
+ elif info['status'] == 204:
+ return None
+ else:
+ self._module.fail_json(msg='Failure while calling the cloudscale.ch API with POST for '
+ '"%s": %s' % (api_call, info['body']))
+
+
+ def _delete(self, api_call):
+ resp, info = fetch_url(self._module,
+ API_URL+api_call,
+ headers = self._auth_header,
+ method='DELETE')
+
+ if info['status'] == 204:
+ return None
+ else:
+ self._module.fail_json(msg='Failure while calling the cloudscale.ch API with DELETE for '
+ '"%s": %s' % (api_call, info['body']))
+
+
+ @staticmethod
+ def _transform_state(server):
+ if 'status' in server:
+ server['state'] = server['status']
+ del server['status']
+ else:
+ server['state'] = 'absent'
+ return server
+
+
+ def update_info(self):
+
+ # If we don't have a UUID (yet) there is nothing to update
+ if not 'uuid' in self.info:
+ return
+
+ # Can't use _get here because we want to handle 404
+ resp, info = fetch_url(self._module,
+ API_URL+'servers/'+self.info['uuid'],
+ headers=self._auth_header)
+ if info['status'] == 200:
+ self.info = self._transform_state(json.loads(resp.read()))
+ elif info['status'] == 404:
+ self.info = {'uuid': self.info['uuid'],
+ 'name': self.info.get('name', None),
+ 'state': 'absent'}
+ else:
+ self._module.fail_json(msg='Failure while calling the cloudscale.ch API for '
+ 'update_info: %s' % info['body'])
+
+
+ def wait_for_state(self, states):
+ start = datetime.now()
+ while datetime.now() - start < timedelta(seconds=TIMEOUT_WAIT):
+ self.update_info()
+ if self.info['state'] in states:
+ return True
+ sleep(1)
+
+ self._module.fail_json(msg='Timeout while waiting for a state change on server %s to states %s. Current state is %s'
+ % (self.info['name'], states, self.info['state']))
+
+
+ def create_server(self):
+ data = self._module.params.copy()
+
+ # check for required parameters to create a server
+ missing_parameters = []
+ for p in ('name', 'ssh_keys', 'image', 'flavor'):
+ if not p in data or not data[p]:
+ missing_parameters.append(p)
+
+ if len(missing_parameters) > 0:
+ self._module.fail_json(msg='Missing required parameter(s) to create a new server: %s' %
+ ' '.join(missing_parameters))
+
+ # Sanitize data dictionary
+ for k,v in data.items():
+
+ # Remove items not relevant to the create server call
+ if k in ('api_token', 'uuid', 'state'):
+ del data[k]
+ continue
+
+ # Remove None values, these don't get correctly translated by urlencode
+ if v is None:
+ del data[k]
+ continue
+
+ self.info = self._transform_state(self._post('servers', data))
+ self.wait_for_state(('running', ))
+
+
+ def delete_server(self):
+ self._delete('servers/%s' % self.info['uuid'])
+ self.wait_for_state(('absent', ))
+
+
+ def start_server(self):
+ self._post('servers/%s/start' % self.info['uuid'])
+ self.wait_for_state(('running', ))
+
+
+ def stop_server(self):
+ self._post('servers/%s/stop' % self.info['uuid'])
+ self.wait_for_state(('stopped', ))
+
+
+ def list_servers(self):
+ return self._get('servers')
+
+
+def main():
+ module = AnsibleModule(
+ argument_spec = dict(
+ state = dict(default='running',
+ choices=ALLOWED_STATES),
+ name = dict(),
+ uuid = dict(),
+ flavor = dict(),
+ image = dict(),
+ volume_size_gb = dict(type='int', default=10),
+ bulk_volume_size_gb = dict(type='int'),
+ ssh_keys = dict(type='list'),
+ use_public_network = dict(type='bool', default=True),
+ use_private_network = dict(type='bool', default=False),
+ use_ipv6 = dict(type='bool', default=True),
+ anti_affinity_with = dict(),
+ user_data = dict(),
+ api_token = dict(no_log=True),
+ ),
+ required_one_of=(('name', 'uuid'),),
+ mutually_exclusive=(('name', 'uuid'),),
+ supports_check_mode=True,
+ )
+
+ api_token = module.params['api_token'] or os.environ.get('CLOUDSCALE_API_TOKEN')
+
+ if not api_token:
+ module.fail_json(msg='The api_token module parameter or the CLOUDSCALE_API_TOKEN '
+ 'environment varialbe are required for this module.')
+
+ target_state = module.params['state']
+ server = AnsibleCloudscaleServer(module, api_token)
+ # The server could be in a changeing or error state.
+ # Wait for one of the allowed states before doing anything.
+ # If an allowed state can't be reached, this module fails.
+ if not server.info['state'] in ALLOWED_STATES:
+ server.wait_for_state(ALLOWED_STATES)
+ current_state = server.info['state']
+
+ if module.check_mode:
+ module.exit_json(changed=not target_state == current_state,
+ **server.info)
+
+ changed = False
+ if current_state == 'absent' and target_state == 'running':
+ server.create_server()
+ changed = True
+ elif current_state == 'absent' and target_state == 'stopped':
+ server.create_server()
+ server.stop_server()
+ changed = True
+ elif current_state == 'stopped' and target_state == 'running':
+ server.start_server()
+ changed = True
+ elif current_state in ('running', 'stopped') and target_state == 'absent':
+ server.delete_server()
+ changed = True
+ elif current_state == 'running' and target_state == 'stopped':
+ server.stop_server()
+ changed = True
+
+ module.exit_json(changed=changed, **server.info)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/integration/Makefile b/test/integration/Makefile
index 5688f409f3..7363231dd4 100644
--- a/test/integration/Makefile
+++ b/test/integration/Makefile
@@ -256,6 +256,11 @@ cloudflare: $(CREDENTIALS_FILE)
RC=$$? ; \
exit $$RC;
+cloudscale:
+ ANSIBLE_ROLES_PATH=$(shell pwd)/targets ansible-playbook cloudscale.yml -i $(INVENTORY) -e @$(VARS_FILE) -e "resource_prefix=$(CLOUD_RESOURCE_PREFIX)" -v $(TEST_FLAGS) ; \
+ RC=$$? ; \
+ exit $$RC;
+
$(CONSUL_RUNNING):
consul:
diff --git a/test/integration/cloudscale.yml b/test/integration/cloudscale.yml
new file mode 100644
index 0000000000..0f5bff42ca
--- /dev/null
+++ b/test/integration/cloudscale.yml
@@ -0,0 +1,7 @@
+- hosts: localhost
+ connection: local
+ gather_facts: no
+ tags:
+ - cloudscale
+ roles:
+ - { role: cloudscale_server, tags: cloudscale_server }
diff --git a/test/integration/targets/cloudscale_server/aliases b/test/integration/targets/cloudscale_server/aliases
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/cloudscale_server/aliases
diff --git a/test/integration/targets/cloudscale_server/defaults/main.yml b/test/integration/targets/cloudscale_server/defaults/main.yml
new file mode 100644
index 0000000000..af078a79be
--- /dev/null
+++ b/test/integration/targets/cloudscale_server/defaults/main.yml
@@ -0,0 +1,5 @@
+---
+cloudscale_test_flavor: flex-2
+cloudscale_test_image: debian-8
+cloudscale_test_ssh_key: |
+ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDSPmiqkvDH1/+MDAVDZT8381aYqp73Odz8cnD5hegNhqtXajqtiH0umVg7HybX3wt1HjcrwKJovZURcIbbcDvzdH2bnYbF93T4OLXA0bIfuIp6M86x1iutFtXdpN3TTicINrmSXEE2Ydm51iMu77B08ZERjVaToya2F7vC+egfoPvibf7OLxE336a5tPCywavvNihQjL8sjgpDT5AAScjb3YqK/6VLeQ18Ggt8/ufINsYkb+9/Ji/3OcGFeflnDXq80vPUyF3u4iIylob6RSZenC38cXmQB05tRNxS1B6BXCjMRdy0v4pa7oKM2GA4ADKpNrr0RI9ed+peRFwmsclH test@ansible \ No newline at end of file
diff --git a/test/integration/targets/cloudscale_server/meta/main.yml b/test/integration/targets/cloudscale_server/meta/main.yml
new file mode 100644
index 0000000000..07faa21776
--- /dev/null
+++ b/test/integration/targets/cloudscale_server/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+ - prepare_tests
diff --git a/test/integration/targets/cloudscale_server/tasks/main.yml b/test/integration/targets/cloudscale_server/tasks/main.yml
new file mode 100644
index 0000000000..b9e983c3ba
--- /dev/null
+++ b/test/integration/targets/cloudscale_server/tasks/main.yml
@@ -0,0 +1,138 @@
+---
+- name: Test create server
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ flavor: '{{ cloudscale_test_flavor }}'
+ image: '{{ cloudscale_test_image }}'
+ ssh_keys: '{{ cloudscale_test_ssh_key }}'
+ register: server
+- name: Verify create server
+ assert:
+ that:
+ - server|success
+ - server|changed
+ - server.state == 'running'
+
+- name: Test create server indempotence
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ flavor: '{{ cloudscale_test_flavor }}'
+ image: '{{ cloudscale_test_image }}'
+ ssh_keys: '{{ cloudscale_test_ssh_key }}'
+ register: server
+- name: Verify create server
+ assert:
+ that:
+ - server|success
+ - not server|changed
+ - server.state == 'running'
+
+- name: Test create server stopped
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test-stopped'
+ flavor: '{{ cloudscale_test_flavor }}'
+ image: '{{ cloudscale_test_image }}'
+ ssh_keys: '{{ cloudscale_test_ssh_key }}'
+ state: 'stopped'
+ register: server_stopped
+- name: Verify create server stopped
+ assert:
+ that:
+ - server_stopped|success
+ - server_stopped|changed
+ - server_stopped.state == 'stopped'
+
+- name: Test create server failure without required parameters
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test-failed'
+ register: server_failed
+ ignore_errors: True
+- name: Verify create server failure without required parameters
+ assert:
+ that:
+ - server_failed|failed
+ - "'Missing required parameter' in server_failed.msg"
+
+- name: Test server stopped
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ state: 'stopped'
+ register: server
+- name: Verify server stopped
+ assert:
+ that:
+ - server|success
+ - server|changed
+ - server.state == 'stopped'
+
+- name: Test server stopped indempotence
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ state: 'stopped'
+ register: server
+- name: Verify server stopped indempotence
+ assert:
+ that:
+ - server|success
+ - not server|changed
+ - server.state == 'stopped'
+
+- name: Test server running
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ state: 'running'
+ register: server
+- name: Verify server running
+ assert:
+ that:
+ - server|success
+ - server|changed
+ - server.state == 'running'
+
+- name: Test server running indempotence
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ state: 'running'
+ register: server
+- name: Verify server running indempotence
+ assert:
+ that:
+ - server|success
+ - not server|changed
+ - server.state == 'running'
+
+- name: Test server deletion by name
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ state: 'absent'
+ register: server
+- name: Verify server deletion
+ assert:
+ that:
+ - server|success
+ - server|changed
+ - server.state == 'absent'
+
+- name: Test server deletion by uuid
+ cloudscale_server:
+ uuid: '{{ server_stopped.uuid }}'
+ state: 'absent'
+ register: server_stopped
+- name: Verify server deletion by uuid
+ assert:
+ that:
+ - server_stopped|success
+ - server_stopped|changed
+ - server_stopped.state == 'absent'
+
+- name: Test server deletion indempotence
+ cloudscale_server:
+ name: '{{ resource_prefix }}-test'
+ state: 'absent'
+ register: server
+- name: Verify server deletion
+ assert:
+ that:
+ - server|success
+ - not server|changed
+ - server.state == 'absent'