From 08e08ac77594e58cf261665f05c2c7596855e61f Mon Sep 17 00:00:00 2001 From: gaudenz Date: Tue, 17 Jan 2017 23:15:20 +0100 Subject: [PATCH] new module cloudscale_server (#20175) --- .../cloud/cloudscale/cloudscale_server.py | 445 ++++++++++++++++++ test/integration/Makefile | 5 + test/integration/cloudscale.yml | 7 + .../targets/cloudscale_server/aliases | 0 .../cloudscale_server/defaults/main.yml | 5 + .../targets/cloudscale_server/meta/main.yml | 2 + .../targets/cloudscale_server/tasks/main.yml | 138 ++++++ 7 files changed, 602 insertions(+) create mode 100644 lib/ansible/modules/cloud/cloudscale/cloudscale_server.py create mode 100644 test/integration/cloudscale.yml create mode 100644 test/integration/targets/cloudscale_server/aliases create mode 100644 test/integration/targets/cloudscale_server/defaults/main.yml create mode 100644 test/integration/targets/cloudscale_server/meta/main.yml create mode 100644 test/integration/targets/cloudscale_server/tasks/main.yml 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 +# +# 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 . + +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 " +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 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'