diff --git a/lib/ansible/module_utils/cloudscale.py b/lib/ansible/module_utils/cloudscale.py new file mode 100644 index 00000000000..e9bbc5b7eb4 --- /dev/null +++ b/lib/ansible/module_utils/cloudscale.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# +# (c) 2017, Gaudenz Steinlin +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import json + +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.urls import fetch_url + +API_URL = 'https://api.cloudscale.ch/v1/' + + +def cloudscale_argument_spec(): + return dict( + api_token=dict(fallback=(env_fallback, ['CLOUDSCALE_API_TOKEN']), + no_log=True, + required=True), + api_timeout=dict(default=30, type='int'), + ) + + +class AnsibleCloudscaleBase(object): + + def __init__(self, module): + self._module = module + self._auth_header = {'Authorization': 'Bearer %s' % module.params['api_token']} + + def _get(self, api_call): + resp, info = fetch_url(self._module, API_URL + api_call, + headers=self._auth_header, + timeout=self._module.params['api_timeout']) + + if info['status'] == 200: + return json.loads(resp.read()) + elif info['status'] == 404: + return None + else: + self._module.fail_json(msg='Failure while calling the cloudscale.ch API with GET for ' + '"%s".' % api_call, fetch_url_info=info) + + def _post(self, api_call, data=None): + headers = self._auth_header.copy() + if data is not None: + data = self._module.jsonify(data) + headers['Content-type'] = 'application/json' + + resp, info = fetch_url(self._module, + API_URL + api_call, + headers=headers, + method='POST', + data=data, + timeout=self._module.params['api_timeout']) + + if info['status'] in (200, 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".' % api_call, fetch_url_info=info) + + def _delete(self, api_call): + resp, info = fetch_url(self._module, + API_URL + api_call, + headers=self._auth_header, + method='DELETE', + timeout=self._module.params['api_timeout']) + + if info['status'] == 204: + return None + else: + self._module.fail_json(msg='Failure while calling the cloudscale.ch API with DELETE for ' + '"%s".' % api_call, fetch_url_info=info) diff --git a/lib/ansible/modules/cloud/cloudscale/cloudscale_floating_ip.py b/lib/ansible/modules/cloud/cloudscale/cloudscale_floating_ip.py new file mode 100644 index 00000000000..468153c7544 --- /dev/null +++ b/lib/ansible/modules/cloud/cloudscale/cloudscale_floating_ip.py @@ -0,0 +1,280 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# (c) 2017, Gaudenz Steinlin +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: cloudscale_floating_ip +short_description: Manages floating IPs on the cloudscale.ch IaaS service +description: + - Create, assign and delete floating IPs 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)." + - A 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 floating IP at least the C(ip_version) and C(server) options are required. + - Once a floating_ip is created all parameters except C(server) are read-only. + - It's not possible to request a floating IP without associating it with a server at the same time. + - This module requires the ipaddress python library. This library is included in Python since version 3.3. It is available as a + module on PyPi for earlier versions. +version_added: 2.5 +author: "Gaudenz Steinlin (@gaudenz) " +options: + state: + description: + - State of the floating IP. + default: present + choices: [ present, absent ] + ip: + description: + - Floating IP address to change. + - Required to assign the IP to a different server or if I(state) is absent. + aliases: [ network ] + ip_version: + description: + - IP protocol version of the floating IP. + choices: [ 4, 6 ] + server: + description: + - UUID of the server assigned to this floating IP. + - Required unless I(state) is absent. + prefix_length: + description: + - Only valid if I(ip_version) is 6. + - Prefix length for the IPv6 network. Currently only a prefix of /56 can be requested. If no I(prefix_length) is present, a + single address is created. + choices: [ 56 ] + reverse_ptr: + description: + - Reverse PTR entry for this address. + - You cannot set a reverse PTR entry for IPv6 floating networks. Reverse PTR entries are only allowed for single addresses. + api_token: + description: + - cloudscale.ch API token. + - This can also be passed in the CLOUDSCALE_API_TOKEN environment variable. + api_timeout: + description: + - Timeout in seconds for calls to the cloudscale.ch API. + default: 30 +''' + +EXAMPLES = ''' +# Request a new floating IP +- name: Request a floating IP + cloudscale_floating_ip: + ip_version: 4 + server: 47cec963-fcd2-482f-bdb6-24461b2d47b1 + reverse_ptr: my-server.example.com + api_token: xxxxxx + register: floating_ip + +# Assign an existing floating IP to a different server +- name: Move floating IP to backup server + cloudscale_floating_ip: + ip: 192.0.2.123 + server: ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Request a new floating IPv6 network +- name: Request a floating IP + cloudscale_floating_ip: + ip_version: 6 + prefix_length: 56 + server: 47cec963-fcd2-482f-bdb6-24461b2d47b1 + api_token: xxxxxx + register: floating_ip + +# Assign an existing floating network to a different server +- name: Move floating IP to backup server + cloudscale_floating_ip: + ip: '{{ floating_ip.network | ip }}' + server: ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Release a floating IP +- name: Release floating IP + cloudscale_floating_ip: + ip: 192.0.2.123 + state: absent + api_token: xxxxxx +''' + +RETURN = ''' +href: + description: The API URL to get details about this floating IP. + returned: success when state == present + type: string + sample: https://api.cloudscale.ch/v1/floating-ips/2001:db8::cafe +network: + description: The CIDR notation of the network that is routed to your server. + returned: success when state == present + type: string + sample: 2001:db8::cafe/128 +next_hop: + description: Your floating IP is routed to this IP address. + returned: success when state == present + type: string + sample: 2001:db8:dead:beef::42 +reverse_ptr: + description: The reverse pointer for this floating IP address. + returned: success when state == present + type: string + sample: 185-98-122-176.cust.cloudscale.ch +server: + description: The floating IP is routed to this server. + returned: success when state == present + type: string + sample: 47cec963-fcd2-482f-bdb6-24461b2d47b1 +ip: + description: The floating IP address or network. This is always present and used to identify floating IPs after creation. + returned: success + type: string + sample: 185.98.122.176 +state: + description: The current status of the floating IP. + returned: success + type: string + sample: present +''' + +import os + +try: + from ipaddress import ip_network + HAS_IPADDRESS = True +except ImportError: + HAS_IPADDRESS = False + +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible.module_utils.cloudscale import AnsibleCloudscaleBase, cloudscale_argument_spec + + +class AnsibleCloudscaleFloatingIP(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleFloatingIP, self).__init__(module) + + # Initialize info dict + # Set state to absent, will be updated by self.update_info() + self.info = {'state': 'absent'} + + if self._module.params['ip']: + self.update_info() + + @staticmethod + def _resp2info(resp): + # If the API response has some content, the floating IP must exist + resp['state'] = 'present' + + # Add the IP address to the response, otherwise handling get's to complicated as this + # has to be converted from the network all the time. + resp['ip'] = str(ip_network(resp['network']).network_address) + + # Replace the server with just the UUID, the href to the server is useless and just makes + # things more complicated + resp['server'] = resp['server']['uuid'] + + return resp + + def update_info(self): + resp = self._get('floating-ips/' + self._module.params['ip']) + if resp: + self.info = self._resp2info(resp) + else: + self.info = {'ip': self._module.params['ip'], + 'state': 'absent'} + + def request_floating_ip(self): + params = self._module.params + + # check for required parameters to request a floating IP + missing_parameters = [] + for p in ('ip_version', 'server'): + if p not in params or not params[p]: + missing_parameters.append(p) + + if len(missing_parameters) > 0: + self._module.fail_json(msg='Missing required parameter(s) to request a floating IP: %s.' % + ' '.join(missing_parameters)) + + data = {'ip_version': params['ip_version'], + 'server': params['server']} + + if params['prefix_length']: + data['prefix_length'] = params['prefix_length'] + if params['reverse_ptr']: + data['reverse_ptr'] = params['reverse_ptr'] + + self.info = self._resp2info(self._post('floating-ips', data)) + + def release_floating_ip(self): + self._delete('floating-ips/%s' % self._module.params['ip']) + self.info = {'ip': self.info['ip'], 'state': 'absent'} + + def update_floating_ip(self): + params = self._module.params + if 'server' not in params or not params['server']: + self._module.fail_json(msg='Missing required parameter to update a floating IP: server.') + self.info = self._resp2info(self._post('floating-ips/%s' % params['ip'], {'server': params['server']})) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + state=dict(default='present', choices=('present', 'absent')), + ip=dict(aliases=('network', )), + ip_version=dict(choices=(4, 6), type='int'), + server=dict(), + prefix_length=dict(choices=(56,), type='int'), + reverse_ptr=dict(), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('ip', 'ip_version'),), + supports_check_mode=True, + ) + + if not HAS_IPADDRESS: + module.fail_json(msg='Could not import the python library ipaddress required by this module') + + target_state = module.params['state'] + target_server = module.params['server'] + floating_ip = AnsibleCloudscaleFloatingIP(module) + current_state = floating_ip.info['state'] + current_server = floating_ip.info['server'] if 'server' in floating_ip.info else None + + if module.check_mode: + module.exit_json(changed=not target_state == current_state or + (current_state == 'present' and current_server != target_server), + **floating_ip.info) + + changed = False + if current_state == 'absent' and target_state == 'present': + floating_ip.request_floating_ip() + changed = True + elif current_state == 'present' and target_state == 'absent': + floating_ip.release_floating_ip() + changed = True + elif current_state == 'present' and current_server != target_server: + floating_ip.update_floating_ip() + changed = True + + module.exit_json(changed=changed, **floating_ip.info) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py b/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py index fc211f76609..1763dd58d8e 100644 --- a/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py +++ b/lib/ansible/modules/cloud/cloudscale/cloudscale_server.py @@ -202,27 +202,23 @@ anti_affinity_with: sample: [] ''' -import json import os from datetime import datetime, timedelta from time import sleep from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.urls import fetch_url +from ansible.module_utils.cloudscale import AnsibleCloudscaleBase, cloudscale_argument_spec - -API_URL = 'https://api.cloudscale.ch/v1/' ALLOWED_STATES = ('running', 'stopped', 'absent', ) -class AnsibleCloudscaleServer(object): +class AnsibleCloudscaleServer(AnsibleCloudscaleBase): - def __init__(self, module, api_token): - self._module = module - self._auth_header = {'Authorization': 'Bearer %s' % api_token} + def __init__(self, module): + super(AnsibleCloudscaleServer, self).__init__(module) # Check if server already exists and load properties uuid = self._module.params['uuid'] @@ -250,49 +246,6 @@ class AnsibleCloudscaleServer(object): 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, timeout=self._module.params['api_timeout']) - - 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".' % api_call, fetch_url_info=info) - - def _post(self, api_call, data=None): - headers = self._auth_header.copy() - if data is not None: - data = self._module.jsonify(data) - headers['Content-type'] = 'application/json' - - resp, info = fetch_url(self._module, - API_URL + api_call, - headers=headers, - method='POST', - data=data, - timeout=self._module.params['api_timeout']) - - 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".' % api_call, fetch_url_info=info) - - def _delete(self, api_call): - resp, info = fetch_url(self._module, - API_URL + api_call, - headers=self._auth_header, - method='DELETE', - timeout=self._module.params['api_timeout']) - - if info['status'] == 204: - return None - else: - self._module.fail_json(msg='Failure while calling the cloudscale.ch API with DELETE for ' - '"%s".' % api_call, fetch_url_info=info) - @staticmethod def _transform_state(server): if 'status' in server: @@ -308,21 +261,15 @@ class AnsibleCloudscaleServer(object): if 'uuid' not in self.info: return - # Can't use _get here because we want to handle 404 url_path = 'servers/' + self.info['uuid'] - resp, info = fetch_url(self._module, - API_URL + url_path, - headers=self._auth_header, - timeout=self._module.params['api_timeout']) - if info['status'] == 200: - self.info = self._transform_state(json.loads(resp.read())) - elif info['status'] == 404: + resp = self._get(url_path) + + if resp: + self.info = self._transform_state(resp) + else: 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 with GET for ' - '"%s".' % url_path, fetch_url_info=info) def wait_for_state(self, states): start = datetime.now() @@ -378,41 +325,36 @@ class AnsibleCloudscaleServer(object): self.wait_for_state(('stopped', )) def list_servers(self): - return self._get('servers') + return self._get('servers') or [] def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(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(), + )) + 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), - api_timeout=dict(default=30, type='int'), - ), + argument_spec=argument_spec, 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) + server = AnsibleCloudscaleServer(module) # 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. diff --git a/test/legacy/cloudscale.yml b/test/legacy/cloudscale.yml index 0f5bff42cab..4c8c03c40aa 100644 --- a/test/legacy/cloudscale.yml +++ b/test/legacy/cloudscale.yml @@ -5,3 +5,4 @@ - cloudscale roles: - { role: cloudscale_server, tags: cloudscale_server } + - { role: cloudscale_floating_ip, tags: cloudscale_floating_ip } diff --git a/test/legacy/roles/cloudscale_floating_ip/defaults/main.yml b/test/legacy/roles/cloudscale_floating_ip/defaults/main.yml new file mode 100644 index 00000000000..f318a20f641 --- /dev/null +++ b/test/legacy/roles/cloudscale_floating_ip/defaults/main.yml @@ -0,0 +1,5 @@ +--- +cloudscale_test_flavor: flex-2 +cloudscale_test_image: debian-9 +cloudscale_test_ssh_key: | + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDSPmiqkvDH1/+MDAVDZT8381aYqp73Odz8cnD5hegNhqtXajqtiH0umVg7HybX3wt1HjcrwKJovZURcIbbcDvzdH2bnYbF93T4OLXA0bIfuIp6M86x1iutFtXdpN3TTicINrmSXEE2Ydm51iMu77B08ZERjVaToya2F7vC+egfoPvibf7OLxE336a5tPCywavvNihQjL8sjgpDT5AAScjb3YqK/6VLeQ18Ggt8/ufINsYkb+9/Ji/3OcGFeflnDXq80vPUyF3u4iIylob6RSZenC38cXmQB05tRNxS1B6BXCjMRdy0v4pa7oKM2GA4ADKpNrr0RI9ed+peRFwmsclH test@ansible diff --git a/test/legacy/roles/cloudscale_floating_ip/meta/main.yml b/test/legacy/roles/cloudscale_floating_ip/meta/main.yml new file mode 100644 index 00000000000..07faa217762 --- /dev/null +++ b/test/legacy/roles/cloudscale_floating_ip/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - prepare_tests diff --git a/test/legacy/roles/cloudscale_floating_ip/tasks/floating_ip.yml b/test/legacy/roles/cloudscale_floating_ip/tasks/floating_ip.yml new file mode 100644 index 00000000000..64872d5148c --- /dev/null +++ b/test/legacy/roles/cloudscale_floating_ip/tasks/floating_ip.yml @@ -0,0 +1,94 @@ +- name: Request floating IP + cloudscale_floating_ip: + server: '{{ test01.uuid }}' + ip_version: '{{ item.ip_version }}' + reverse_ptr: '{{ item.reverse_ptr | default(omit) }}' + prefix_length: '{{ item.prefix_length | default(omit) }}' + register: floating_ip +- name: Verify request floating IP + assert: + that: + - floating_ip | success + - floating_ip | changed + - (item.ip_version == 4 and floating_ip.ip | ipv4) or (item.ip_version == 6 and floating_ip.ip | ipv6) + - floating_ip.server == test01.uuid + +- name: Check floating IP indempotence + cloudscale_floating_ip: + server: '{{ test01.uuid }}' + ip: '{{ floating_ip.ip }}' + register: floating_ip_indempotence +- name: Verify floating IP indempotence + assert: + that: + - floating_ip_indempotence | success + - not floating_ip_indempotence | changed + - floating_ip_indempotence.server == test01.uuid + +- name: Check network parameter alias + cloudscale_floating_ip: + server: '{{ test01.uuid }}' + network: '{{ floating_ip.ip }}' + register: floating_ip_network +- name: Verify network parameter alias + assert: + that: + - floating_ip_network | success + +- name: Move floating IP to second server + cloudscale_floating_ip: + server: '{{ test02.uuid }}' + ip: '{{ floating_ip.ip }}' + register: move_ip +- name: Verify move floating IPv4 to second server + assert: + that: + - move_ip | success + - move_ip | changed + - move_ip.server == test02.uuid + +- name: Fail if server is missing on update + cloudscale_floating_ip: + ip: '{{ floating_ip.ip }}' + register: update_failed + ignore_errors: True +- name: Verify fail if server is missing on update + assert: + that: + - update_failed | failed + - "'Missing required parameter' in update_failed.msg" + +- name: Release floating IP + cloudscale_floating_ip: + ip: '{{ floating_ip.ip }}' + state: 'absent' + register: release_ip +- name: Verify release floating IPs + assert: + that: + - release_ip | success + - release_ip | changed + - release_ip.state == 'absent' + +- name: Release floating IP indempotence + cloudscale_floating_ip: + ip: '{{ floating_ip.ip }}' + state: 'absent' + register: release_ip +- name: Verify release floating IPs indempotence + assert: + that: + - release_ip | success + - not release_ip | changed + - release_ip.state == 'absent' + +- name: Fail if server is missing on request + cloudscale_floating_ip: + ip_version: 6 + register: request_failed + ignore_errors: True +- name: Verify fail if server is missing on request + assert: + that: + - request_failed | failed + - "'Missing required parameter' in request_failed.msg" diff --git a/test/legacy/roles/cloudscale_floating_ip/tasks/main.yml b/test/legacy/roles/cloudscale_floating_ip/tasks/main.yml new file mode 100644 index 00000000000..fb33e634fe3 --- /dev/null +++ b/test/legacy/roles/cloudscale_floating_ip/tasks/main.yml @@ -0,0 +1,33 @@ +- name: Cloudscale floating IP tests + block: + - name: Create a server + cloudscale_server: + name: '{{ resource_prefix }}-test01' + flavor: '{{ cloudscale_test_flavor }}' + image: '{{ cloudscale_test_image }}' + ssh_keys: '{{ cloudscale_test_ssh_key }}' + register: test01 + + - name: Create a second server + cloudscale_server: + name: '{{ resource_prefix }}-test02' + flavor: '{{ cloudscale_test_flavor }}' + image: '{{ cloudscale_test_image }}' + ssh_keys: '{{ cloudscale_test_ssh_key }}' + register: test02 + + - include_tasks: floating_ip.yml + with_items: + - { 'ip_version': 4, 'reverse_ptr': 'my-floating-ipv4.example.com' } + - { 'ip_version': 6 } + - { 'ip_version': 6, 'prefix_length': 56 } + + always: + - name: Delete servers + cloudscale_server: + uuid: '{{ item.uuid }}' + state: 'absent' + ignore_errors: True + with_items: + - '{{ test01 }}' + - '{{ test02 }}'