diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
commit | 975f66f2eebe9dadba04f275774d4ab83f74cf25 (patch) | |
tree | 89bd26a93aaae6a25749145b7e4bca4a1e75b2be /ansible_collections/cloudscale_ch/cloud/plugins/modules | |
parent | Initial commit. (diff) | |
download | ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.tar.xz ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.zip |
Adding upstream version 7.7.0+dfsg.upstream/7.7.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/cloudscale_ch/cloud/plugins/modules')
14 files changed, 4051 insertions, 0 deletions
diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/__init__.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/__init__.py diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/custom_image.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/custom_image.py new file mode 100644 index 000000000..61e2f77f7 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/custom_image.py @@ -0,0 +1,468 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021, Ciril Troxler <ciril.troxler@cloudscale.ch> +# 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 + + +DOCUMENTATION = r''' +--- +module: custom_image +short_description: Manage custom images on the cloudscale.ch IaaS service +description: + - Import, modify and delete custom images. +notes: + - To import a new custom-image the I(url) and I(name) options are required. +author: + - Ciril Troxler (@ctx) + - Gaudenz Steinlin (@gaudenz) +version_added: 2.2.0 +options: + url: + description: + - The URL used to download the image. + type: str + force_retry: + description: + - Retry the image import even if a failed import using the same name and + URL already exists. This is necessary to recover from download errors. + default: false + type: bool + name: + description: + - The human readable name of the custom image. Either name or UUID must + be present to change an existing image. + type: str + uuid: + description: + - The unique identifier of the custom image import. Either name or UUID + must be present to change an existing image. + type: str + slug: + description: + - A string identifying the custom image for use within the API. + type: str + user_data_handling: + description: + - How user_data will be handled when creating a server. There are + currently two options, "pass-through" and "extend-cloud-config". + type: str + choices: [ pass-through, extend-cloud-config ] + zones: + description: + - Specify zones in which the custom image will be available (e.g. C(lpg1) + or C(rma1)). + type: list + elements: str + source_format: + description: + - The file format of the image referenced in the url. Currently only raw + is supported. + type: str + firmware_type: + description: + - The firmware type that will be used for servers created + with this image. + type: str + choices: [ bios, uefi ] + default: bios + tags: + description: + - The tags assigned to the custom image. + type: dict + state: + description: State of the coustom image. + choices: [ present, absent ] + default: present + type: str +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = r''' +- name: Import custom image + cloudscale_ch.cloud.custom_image: + name: "My Custom Image" + url: https://ubuntu.com/downloads/hirsute.img + slug: my-custom-image + user_data_handling: extend-cloud-config + zones: lpg1 + tags: + project: luna + state: present + register: my_custom_image + +- name: Wait until import succeeded + cloudscale_ch.cloud.custom_image: + uuid: "{{ my_custom_image.uuid }}" + retries: 15 + delay: 5 + register: image + until: image.import_status == 'success' + failed_when: image.import_status == 'failed' + +- name: Import custom image and wait until import succeeded + cloudscale_ch.cloud.custom_image: + name: "My Custom Image" + url: https://ubuntu.com/downloads/hirsute.img + slug: my-custom-image + user_data_handling: extend-cloud-config + zones: lpg1 + tags: + project: luna + state: present + retries: 15 + delay: 5 + register: image + until: image.import_status == 'success' + failed_when: image.import_status == 'failed' + +- name: Import custom image with UEFI firmware type + cloudscale_ch.cloud.custom_image: + name: "My Custom UEFI Image" + url: https://ubuntu.com/downloads/hirsute.img + slug: my-custom-uefi-image + user_data_handling: extend-cloud-config + zones: lpg1 + firmware_type: uefi + tags: + project: luna + state: present + register: my_custom_image + +- name: Update custom image + cloudscale_ch.cloud.custom_image: + name: "My Custom Image" + slug: my-custom-image + user_data_handling: extend-cloud-config + tags: + project: luna + state: present + +- name: Delete custom image + cloudscale_ch.cloud.custom_image: + uuid: '{{ my_custom_image.uuid }}' + state: absent + +- name: List all custom images + uri: + url: 'https://api.cloudscale.ch/v1/custom-images' + headers: + Authorization: 'Bearer {{ query("env", "CLOUDSCALE_API_TOKEN") }}' + status_code: 200 + register: image_list +- name: Search the image list for all images with name 'My Custom Image' + set_fact: + my_custom_images: '{{ image_list.json | selectattr("name","search", "My Custom Image" ) }}' +''' + +RETURN = r''' +href: + description: The API URL to get details about this resource. + returned: success when state == present + type: str + sample: https://api.cloudscale.ch/v1/custom-imges/11111111-1864-4608-853a-0771b6885a3a +uuid: + description: The unique identifier of the custom image. + returned: success + type: str + sample: 11111111-1864-4608-853a-0771b6885a3a +name: + description: The human readable name of the custom image. + returned: success + type: str + sample: alan +created_at: + description: The creation date and time of the resource. + returned: success + type: str + sample: "2020-05-29T13:18:42.511407Z" +slug: + description: A string identifying the custom image for use within the API. + returned: success + type: str + sample: foo +checksums: + description: The checksums of the custom image as key and value pairs. The + algorithm (e.g. sha256) name is in the key and the checksum in the value. + The set of algorithms used might change in the future. + returned: success + type: dict + sample: { + "md5": "5b3a1f21cde154cfb522b582f44f1a87", + "sha256": "5b03bcbd00b687e08791694e47d235a487c294e58ca3b1af704120123aa3f4e6" + } +user_data_handling: + description: How user_data will be handled when creating a server. There are + currently two options, "pass-through" and "extend-cloud-config". + returned: success + type: str + sample: "pass-through" +tags: + description: Tags assosiated with the custom image. + returned: success + type: dict + sample: { 'project': 'my project' } +import_status: + description: Shows the progress of an import. Values are one of + "started", "in_progress", "success" or "failed". + returned: success + type: str + sample: "in_progress" +error_message: + description: Error message in case of a failed import. + returned: success + type: str + sample: "Expected HTTP 200, got HTTP 403" +state: + description: The current status of the custom image. + returned: success + type: str + sample: present +''' + + +from ansible.module_utils.basic import ( + AnsibleModule, +) +from ansible.module_utils.urls import ( + fetch_url +) +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) +from ansible.module_utils._text import ( + to_text +) + + +class AnsibleCloudscaleCustomImage(AnsibleCloudscaleBase): + + def _transform_import_to_image(self, imp): + # Create a stub image from the import + img = imp.get('custom_image', {}) + return { + 'href': img.get('href'), + 'uuid': imp['uuid'], + 'name': img.get('name'), + 'created_at': None, + 'size_gb': None, + 'checksums': None, + 'tags': imp['tags'], + 'url': imp['url'], + 'import_status': imp['status'], + 'error_message': imp.get('error_message', ''), + # Even failed image imports are reported as present. This then + # represents a failed import resource. + 'state': 'present', + # These fields are not present on the import, assume they are + # unchanged from the module parameters + 'user_data_handling': self._module.params['user_data_handling'], + 'zones': self._module.params['zones'], + 'slug': self._module.params['slug'], + 'firmware_type': self._module.params['firmware_type'], + } + + # This method can be replaced by calling AnsibleCloudscaleBase._get form + # AnsibleCloudscaleCustomImage._get once the API bug is fixed. + def _get_url(self, url): + + response, info = fetch_url(self._module, + url, + headers=self._auth_header, + method='GET', + timeout=self._module.params['api_timeout']) + + if info['status'] == 200: + response = self._module.from_json( + to_text(response.read(), + errors='surrogate_or_strict'), + ) + elif info['status'] == 404: + # Return None to be compatible with AnsibleCloudscaleBase._get + response = None + elif info['status'] == 500 and url.startswith(self._api_url + self.resource_name + '/import/'): + # Workaround a bug in the cloudscale.ch API which wrongly returns + # 500 instead of 404 + response = None + else: + self._module.fail_json( + msg='Failure while calling the cloudscale.ch API with GET for ' + '"%s"' % url, + fetch_url_info=info, + ) + + return response + + def _get(self, api_call): + + # Split api_call into components + api_url, call_uuid = api_call.split(self.resource_name) + + # If the api_call does not contain the API URL + if not api_url: + api_url = self._api_url + + # Fetch image(s) from the regular API endpoint + response = self._get_url(api_url + self.resource_name + call_uuid) or [] + + # Additionally fetch image(s) from the image import API endpoint + response_import = self._get_url( + api_url + self.resource_name + '/import' + call_uuid, + ) or [] + + # No image was found + if call_uuid and response == [] and response_import == []: + return None + + # Convert single image responses (call with UUID) into a list + if call_uuid and response: + response = [response] + if call_uuid and response_import: + response_import = [response_import] + + # Transform lists into UUID keyed dicts + response = dict([(i['uuid'], i) for i in response]) + response_import = dict([(i['uuid'], i) for i in response_import]) + + # Filter the import list so that successfull and in_progress imports + # shadow failed imports + response_import_filtered = dict([(k, v) for k, v + in response_import.items() + if v['status'] in ('success', + 'in_progress')]) + # Only add failed imports if no import with the same name exists + # Only add the last failed import in the list (there is no timestamp on + # imports) + import_names = set([v['custom_image']['name'] for k, v + in response_import_filtered.items()]) + for k, v in reversed(list(response_import.items())): + name = v['custom_image']['name'] + if (v['status'] == 'failed' and name not in import_names): + import_names.add(name) + response_import_filtered[k] = v + + # Merge import list into image list + for uuid, imp in response_import_filtered.items(): + if uuid in response: + # Merge addtional fields only present on the import + response[uuid].update( + url=imp['url'], + import_status=imp['status'], + error_message=imp.get('error_message', ''), + ) + else: + response[uuid] = self._transform_import_to_image(imp) + + if not call_uuid: + return response.values() + else: + return next(iter(response.values())) + + def _post(self, api_call, data=None): + # Only new image imports are supported, no direct POST call to image + # resources are supported by the API + if not api_call.endswith('custom-images'): + self._module.fail_json(msg="Error: Bad api_call URL.") + # Custom image imports use a different endpoint + api_call += '/import' + + if self._module.params['url']: + return self._transform_import_to_image( + self._post_or_patch("%s" % api_call, 'POST', data), + ) + else: + self._module.fail_json(msg="Cannot import a new image without url.") + + def present(self): + resource = self.query() + + # If the module passes the firmware_type argument, + # and the module argument and API response are not the same for + # argument firmware_type. + if (resource.get('firmware_type') is not None + and resource.get('firmware_type') != + self._module.params['firmware_type']): + # Custom error if the module tries to change the firmware_type. + msg = "Cannot change firmware type of an existing custom image" + self._module.fail_json(msg) + + if resource['state'] == "absent": + resource = self.create(resource) + else: + # If this is a failed upload and the URL changed or the "force_retry" + # parameter is used, create a new image import. + if (resource.get('import_status') == 'failed' + and (resource['url'] != self._module.params['url'] + or self._module.params['force_retry'])): + resource = self.create(resource) + else: + resource = self.update(resource) + + return self.get_result(resource) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(type='str'), + slug=dict(type='str'), + url=dict(type='str'), + force_retry=dict(type='bool', default=False), + user_data_handling=dict(type='str', + choices=('pass-through', + 'extend-cloud-config')), + uuid=dict(type='str'), + firmware_type=dict(type='str', + choices=('bios', + 'uefi'), + default=('bios')), + tags=dict(type='dict'), + state=dict(type='str', default='present', + choices=('present', 'absent')), + zones=dict(type='list', elements='str'), + source_format=dict(type='str'), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('name', 'uuid'),), + supports_check_mode=True, + ) + + cloudscale_custom_image = AnsibleCloudscaleCustomImage( + module, + resource_name='custom-images', + resource_key_uuid='uuid', + resource_key_name='name', + resource_create_param_keys=[ + 'name', + 'slug', + 'url', + 'user_data_handling', + 'firmware_type', + 'tags', + 'zones', + 'source_format', + ], + resource_update_param_keys=[ + 'name', + 'slug', + 'user_data_handling', + 'firmware_type', + 'tags', + ], + ) + + if module.params['state'] == "absent": + result = cloudscale_custom_image.absent() + else: + result = cloudscale_custom_image.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/floating_ip.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/floating_ip.py new file mode 100644 index 000000000..7f578d18c --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/floating_ip.py @@ -0,0 +1,285 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: 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. +notes: + - Once a floating_ip is created, all parameters except C(server), C(reverse_ptr) and C(tags) are read-only. +author: + - Gaudenz Steinlin (@gaudenz) + - Denis Krienbühl (@href) + - René Moser (@resmo) +version_added: 1.0.0 +options: + network: + description: + - Floating IP address to change. + - One of I(network) or I(name) is required to identify the floating IP. + aliases: [ ip ] + type: str + name: + description: + - Name to identifiy the floating IP address for idempotency. + - One of I(network) or I(name) is required to identify the floating IP. + - Required for assigning a new floating IP. + version_added: 1.3.0 + type: str + state: + description: + - State of the floating IP. + default: present + choices: [ present, absent ] + type: str + ip_version: + description: + - IP protocol version of the floating IP. + - Required when assigning a new floating IP. + choices: [ 4, 6 ] + type: int + server: + description: + - UUID of the server assigned to this floating IP. + type: str + type: + description: + - The type of the floating IP. + choices: [ regional, global ] + type: str + default: regional + region: + description: + - Region in which the floating IP resides (e.g. C(lpg) or C(rma)). + If omitted, the region of the project default zone is used. + This parameter must be omitted if I(type) is set to C(global). + type: str + 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 ] + type: int + 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. + type: str + tags: + description: + - Tags associated with the floating IP. Set this to C({}) to clear any tags. + type: dict + version_added: 1.1.0 +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Request a new floating IP without assignment to a server +- name: Request a floating IP + cloudscale_ch.cloud.floating_ip: + name: IP to my server + ip_version: 4 + reverse_ptr: my-server.example.com + api_token: xxxxxx + +# Request a new floating IP with assignment +- name: Request a floating IP + cloudscale_ch.cloud.floating_ip: + name: web + ip_version: 4 + server: 47cec963-fcd2-482f-bdb6-24461b2d47b1 + reverse_ptr: my-server.example.com + api_token: xxxxxx + +# Assign an existing floating IP to a different server by its IP address +- name: Move floating IP to backup server + cloudscale_ch.cloud.floating_ip: + ip: 192.0.2.123 + server: ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Assign an existing floating IP to a different server by name +- name: Move floating IP to backup server + cloudscale_ch.cloud.floating_ip: + name: IP to my server + server: ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Request a new floating IPv6 network +- name: Request a floating IP + cloudscale_ch.cloud.floating_ip: + name: IPv6 to my server + ip_version: 6 + prefix_length: 56 + server: 47cec963-fcd2-482f-bdb6-24461b2d47b1 + api_token: xxxxxx + region: lpg1 + +# Assign an existing floating network to a different server +- name: Move floating IP to backup server + cloudscale_ch.cloud.floating_ip: + ip: '{{ floating_ip.ip }}' + server: ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Remove a floating IP +- name: Release floating IP + cloudscale_ch.cloud.floating_ip: + ip: 192.0.2.123 + state: absent + api_token: xxxxxx + +# Remove a floating IP by name +- name: Release floating IP + cloudscale_ch.cloud.floating_ip: + name: IP to my server + state: absent + api_token: xxxxxx +''' + +RETURN = ''' +name: + description: The name of the floating IP. + returned: success + type: str + sample: my floating ip + version_added: 1.3.0 +href: + description: The API URL to get details about this floating IP. + returned: success when state == present + type: str + 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 + type: str + sample: 2001:db8::cafe/128 +next_hop: + description: Your floating IP is routed to this IP address. + returned: success when state == present + type: str + sample: 2001:db8:dead:beef::42 +reverse_ptr: + description: The reverse pointer for this floating IP address. + returned: success when state == present + type: str + sample: 185-98-122-176.cust.cloudscale.ch +server: + description: The floating IP is routed to this server. + returned: success when state == present + type: str + sample: 47cec963-fcd2-482f-bdb6-24461b2d47b1 +ip: + description: The floating IP address. + returned: success when state == present + type: str + sample: 185.98.122.176 +region: + description: The region of the floating IP. + returned: success when state == present + type: dict + sample: {'slug': 'lpg'} +state: + description: The current status of the floating IP. + returned: success + type: str + sample: present +tags: + description: Tags assosiated with the floating IP. + returned: success + type: dict + sample: { 'project': 'my project' } + version_added: 1.1.0 +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + + +class AnsibleCloudscaleFloatingIp(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleFloatingIp, self).__init__( + module=module, + resource_key_uuid='network', + resource_name='floating-ips', + resource_create_param_keys=[ + 'ip_version', + 'server', + 'prefix_length', + 'reverse_ptr', + 'type', + 'region', + 'tags', + ], + resource_update_param_keys=[ + 'server', + 'reverse_ptr', + 'tags', + ], + ) + self.use_tag_for_name = True + self.query_constraint_keys = ['ip_version'] + + def pre_transform(self, resource): + if 'server' in resource and isinstance(resource['server'], dict): + resource['server'] = resource['server']['uuid'] + return resource + + def create(self, resource): + # Fail when missing params for creation + self._module.fail_on_missing_params(['ip_version', 'name']) + return super(AnsibleCloudscaleFloatingIp, self).create(resource) + + def get_result(self, resource): + network = resource.get('network') + if network: + self._result['ip'] = network.split('/')[0] + return super(AnsibleCloudscaleFloatingIp, self).get_result(resource) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(type='str'), + state=dict(default='present', choices=('present', 'absent'), type='str'), + network=dict(aliases=('ip',), type='str'), + ip_version=dict(choices=(4, 6), type='int'), + server=dict(type='str'), + type=dict(type='str', choices=('regional', 'global'), default='regional'), + region=dict(type='str'), + prefix_length=dict(choices=(56,), type='int'), + reverse_ptr=dict(type='str'), + tags=dict(type='dict'), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('network', 'name'),), + supports_check_mode=True, + ) + + cloudscale_floating_ip = AnsibleCloudscaleFloatingIp(module) + + if module.params['state'] == 'absent': + result = cloudscale_floating_ip.absent() + else: + result = cloudscale_floating_ip.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer.py new file mode 100644 index 000000000..4e5682979 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer.py @@ -0,0 +1,240 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2023, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright: (c) 2023, Kenneth Joss <kenneth.joss@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: load_balancer +short_description: Manages load balancers on the cloudscale.ch IaaS service +description: + - Get, create, update, delete load balancers on the cloudscale.ch IaaS service. +notes: + - If I(uuid) option is provided, it takes precedence over I(name) for load balancer selection. This allows to update the load balancers's name. + - If no I(uuid) option is provided, I(name) is used for load balancer selection. If more than one load balancer with this name exists, execution is aborted. +author: + - Gaudenz Steinlin (@gaudenz) + - Kenneth Joss (@k-304) +version_added: "2.3.0" +options: + state: + description: + - State of the load balancer. + choices: [ present, absent ] + default: present + type: str + name: + description: + - Name of the load balancer. + - Either I(name) or I(uuid) are required. + type: str + uuid: + description: + - UUID of the load balancer. + - Either I(name) or I(uuid) are required. + type: str + flavor: + description: + - Flavor of the load balancer. + default: lb-standard + type: str + vip_addresses: + description: + - See the [API documentation](https://www.cloudscale.ch/en/api/v1#vip_addresses-attribute-specification) for details about this parameter. + type: list + elements: dict + suboptions: + subnet: + description: + - Create a VIP address on the subnet identified by this UUID. + type: str + address: + description: + - Use this address. + - Must be in the same range as subnet. + - If empty, a radom address will be used. + type: str + zone: + description: + - Zone in which the load balancer resides (e.g. C(lpg1) or C(rma1)). + type: str + tags: + description: + - Tags assosiated with the load balancer. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create and start a load balancer +- name: Start cloudscale.ch load balancer + cloudscale_ch.cloud.load_balancer: + name: my-shiny-cloudscale-load-balancer + flavor: lb-standard + zone: rma1 + tags: + project: my project + api_token: xxxxxx + +# Create and start a load balancer with specific subnet +- name: Start cloudscale.ch load balancer + cloudscale_ch.cloud.load_balancer: + name: my-shiny-cloudscale-load-balancer + flavor: lb-standard + vip_addresses: + - subnet: d7b82c9b-5900-436c-9296-e94dca01c7a0 + address: 172.25.12.1 + zone: lpg1 + tags: + project: my project + api_token: xxxxxx + +# Get load balancer facts by name +- name: Get facts of a load balancer + cloudscale_ch.cloud.load_balancer: + name: my-shiny-cloudscale-load-balancer + api_token: xxxxxx +''' + +RETURN = ''' +href: + description: API URL to get details about this load balancer + returned: success when not state == absent + type: str + sample: https://api.cloudscale.ch/v1/load-balancers/0f62e0a7-f459-4fc4-9c25-9e57b6cb4b2f +uuid: + description: The unique identifier for this load balancer + returned: success + type: str + sample: cfde831a-4e87-4a75-960f-89b0148aa2cc +name: + description: The display name of the load balancer + returned: success + type: str + sample: web-lb +created_at: + description: The creation date and time of the load balancer + returned: success when not state == absent + type: str + sample: "2023-02-07T15:32:02.308041Z" +status: + description: The current operational status of the load balancer + returned: success + type: str + sample: running +state: + description: The current state of the load balancer + returned: success + type: str + sample: present +zone: + description: The zone used for this load balancer + returned: success when not state == absent + type: dict + sample: { 'slug': 'lpg1' } +flavor: + description: The flavor that has been used for this load balancer + returned: success when not state == absent + type: list + sample: { "slug": "lb-standard", "name": "LB-Standard" } +vip_addresses: + description: List of vip_addresses for this load balancer + returned: success when not state == absent + type: dict + sample: [ {"version": "4", "address": "192.0.2.110", + "subnet": [ + "href": "https://api.cloudscale.ch/v1/subnets/92c70b2f-99cb-4811-8823-3d46572006e4", + "uuid": "92c70b2f-99cb-4811-8823-3d46572006e4", + "cidr": "192.0.2.0/24" + ]} ] +tags: + description: Tags assosiated with the load balancer + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + +ALLOWED_STATES = ('present', + 'absent', + ) + + +class AnsibleCloudscaleLoadBalancer(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleLoadBalancer, self).__init__( + module, + resource_name='load-balancers', + resource_create_param_keys=[ + 'name', + 'flavor', + 'zone', + 'vip_addresses', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'tags', + ], + ) + + def create(self, resource, data=None): + super().create(resource) + if not self._module.check_mode: + resource = self.wait_for_state('status', ('running', )) + return resource + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(type='str'), + uuid=dict(type='str'), + flavor=dict(type='str', default='lb-standard'), + zone=dict(type='str'), + vip_addresses=dict( + type='list', + elements='dict', + options=dict( + subnet=dict(type='str'), + address=dict(type='str'), + ), + ), + tags=dict(type='dict'), + state=dict(type='str', default='present', choices=ALLOWED_STATES), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=(), + required_one_of=(('name', 'uuid'),), + required_if=(('state', 'present', ('name',),),), + supports_check_mode=True, + ) + + cloudscale_load_balancer = AnsibleCloudscaleLoadBalancer(module) + cloudscale_load_balancer.query_constraint_keys = [ + 'zone', + ] + + if module.params['state'] == "absent": + result = cloudscale_load_balancer.absent() + else: + result = cloudscale_load_balancer.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_health_monitor.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_health_monitor.py new file mode 100644 index 000000000..cf99e0cd3 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_health_monitor.py @@ -0,0 +1,398 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2023, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright: (c) 2023, Kenneth Joss <kenneth.joss@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: load_balancer_health_monitor +short_description: Manages load balancers on the cloudscale.ch IaaS service +description: + - Get, create, update, delete health monitors on the cloudscale.ch IaaS service. +notes: + - Health monitors do not have names. I(uuid)'s are used to reference a health monitors. +author: + - Gaudenz Steinlin (@gaudenz) + - Kenneth Joss (@k-304) +version_added: "2.3.0" +options: + state: + description: + - State of the load balancer health monitor. + choices: [ present, absent ] + default: present + type: str + uuid: + description: + - UUID of the load balancer health monitor. + type: str + pool: + description: + - The pool of the health monitor. + type: str + delay_s: + description: + - The delay between two successive checks in seconds. + type: int + timeout_s: + description: + - The maximum time allowed for an individual check in seconds. + type: int + up_threshold: + description: + - The number of checks that need to be successful before the monitor_status of a pool member changes to "up". + type: int + down_threshold: + description: + - The number of checks that need to fail before the monitor_status of a pool member changes to "down". + type: int + type: + description: + - The type of the health monitor. + - See the [API documentation](https://www.cloudscale.ch/en/api/v1#create-a-health-monitor) for allowed options. + type: str + http: + description: + - Advanced options for health monitors with type "http" or "https". + type: dict + suboptions: + expected_codes: + description: + - The HTTP status codes allowed for a check to be considered successful. + - See the [API documentation](https://www.cloudscale.ch/en/api/v1#http-attribute-specification) for details. + type: list + elements: str + method: + description: + - The HTTP method used for the check. + type: str + url_path: + description: + - The URL used for the check. + type: str + version: + description: + - The HTTP version used for the check. + type: str + host: + description: + - The server name in the HTTP Host header used for the check. + - Requires version to be set to "1.1". + type: str + tags: + description: + - Tags assosiated with the load balancer. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create a simple health monitor for a pool +- name: Create a load balancer pool + cloudscale_ch.cloud.load_balancer_pool: + name: 'swimming-pool' + load_balancer: '3d41b118-f95c-4897-ad74-2260fea783fc' + algorithm: 'round_robin' + protocol: 'tcp' + api_token: xxxxxx + register: load_balancer_pool + +- name: Create a load balancer health monitor (ping) + cloudscale_ch.cloud.load_balancer_health_monitor: + pool: '{{ load_balancer_pool.uuid }}' + type: 'ping' + api_token: xxxxxx + register: load_balancer_health_monitor + +# Get load balancer health monitor facts by UUID +- name: Get facts of a load balancer health monitor by UUID + cloudscale_ch.cloud.load_balancer_health_monitor: + uuid: '{{ load_balancer_health_monitor.uuid }}' + api_token: xxxxxx + +# Update a health monitor +- name: Update HTTP method of a load balancer health monitor from GET to CONNECT + cloudscale_ch.cloud.load_balancer_health_monitor: + uuid: '{{ load_balancer_health_monitor_http.uuid }}' + delay_s: 2 + timeout_s: 1 + up_threshold: 2 + down_threshold: 3 + type: 'http' + http: + expected_codes: + - 200 + - 202 + method: 'CONNECT' + url_path: '/' + version: '1.1' + host: 'host1' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + register: load_balancer_health_monitor +''' + +RETURN = ''' +href: + description: API URL to get details about this load balancer health monitor + returned: success when not state == absent + type: str + sample: https://api.cloudscale.ch/v1/load-balancers/health-monitors/ee4952d4-2eba-4dec-8957-7911b3ce245b +uuid: + description: The unique identifier for this load balancer health monitor + returned: success + type: str + sample: ee4952d4-2eba-4dec-8957-7911b3ce245b +created_at: + description: The creation date and time of the load balancer health monitor + returned: success when not state == absent + type: str + sample: "2023-02-22T09:55:38.285018Z" +pool: + description: The pool of the health monitor + returned: success when not state == absent + type: dict + sample: [ + "href": "https://api.cloudscale.ch/v1/load-balancers/pools/618a6cc8-d757-4fab-aa10-d49dc47e667b", + "uuid": "618a6cc8-d757-4fab-aa10-d49dc47e667b", + "name": "swimming pool" + ] +delay_s: + description: The delay between two successive checks in seconds + returned: success when not state == absent + type: int + sample: 2 +timeout_s: + description: The maximum time allowed for an individual check in seconds + returned: success when not state == absent + type: int + sample: 1 +up_threshold: + description: The number of checks that need to be successful before the monitor_status of a pool member changes to "up" + returned: success when not state == absent + type: int + sample: 2 +down_threshold: + description: The number of checks that need to fail before the monitor_status of a pool member changes to "down" + returned: success when not state == absent + type: int + sample: 3 +type: + description: The type of the health monitor + returned: success when not state == absent + type: str +http: + description: Advanced options for health monitors with type "http" or "https" + returned: success when not state == absent + type: dict + sample: [ { + "expected_codes": [ + "200" + ], + "method": "GET", + "url_path": "/", + "version": "1.0", + "host": null + } ] +tags: + description: Tags assosiated with the load balancer + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + +ALLOWED_STATES = ('present', + 'absent', + ) +ALLOWED_HTTP_POST_PARAMS = ('expected_codes', + 'host', + 'method', + 'url_path') + + +class AnsibleCloudscaleLoadBalancerHealthMonitor(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleLoadBalancerHealthMonitor, self).__init__( + module, + resource_name='load-balancers/health-monitors', + resource_key_name='pool', + resource_create_param_keys=[ + 'pool', + 'timeout_s', + 'up_threshold', + 'down_threshold', + 'type', + 'http', + 'tags', + ], + resource_update_param_keys=[ + 'delay_s', + 'timeout_s', + 'up_threshold', + 'down_threshold', + 'expected_codes', + 'http', + 'tags', + ], + ) + + def query(self): + # Initialize + self._resource_data = self.init_resource() + + resource_key_pool = 'pool' + uuid = self._module.params[self.resource_key_uuid] + pool = self._module.params[resource_key_pool] + matching = [] + + # Either search by given health monitor's UUID or + # search the health monitor by its acossiated pool UUID (1:1) + if uuid is not None: + super().query() + else: + pool = self._module.params[resource_key_pool] + if pool is not None: + + resources = self._get('%s' % (self.resource_name)) + + if resources: + for health_monitor in resources: + if health_monitor[resource_key_pool]['uuid'] == pool: + matching.append(health_monitor) + + # Fail on more than one resource with identical name + if len(matching) > 1: + self._module.fail_json( + msg="More than one %s resource for pool '%s' exists." % ( + self.resource_name, + resource_key_pool + ) + ) + elif len(matching) == 1: + self._resource_data = matching[0] + self._resource_data['state'] = "present" + + return self.pre_transform(self._resource_data) + + def update(self, resource): + updated = False + for param in self.resource_update_param_keys: + if param == 'http' and self._module.params.get('http') is not None: + for subparam in ALLOWED_HTTP_POST_PARAMS: + updated = self._http_param_updated(subparam, resource) or updated + else: + updated = self._param_updated(param, resource) or updated + + # Refresh if resource was updated in live mode + if updated and not self._module.check_mode: + resource = self.query() + return resource + + def _http_param_updated(self, key, resource): + param_http = self._module.params.get('http') + param = param_http[key] + + if param is None: + return False + + if not resource or key not in resource['http']: + return False + + is_different = self.find_http_difference(key, resource, param) + + if is_different: + self._result['changed'] = True + + patch_data = { + 'http': { + key: param + } + } + + before_data = { + 'http': { + key: resource['http'][key] + } + } + + self._result['diff']['before'].update(before_data) + self._result['diff']['after'].update(patch_data) + + if not self._module.check_mode: + href = resource.get('href') + if not href: + self._module.fail_json(msg='Unable to update %s, no href found.' % key) + + self._patch(href, patch_data) + return True + return False + + def find_http_difference(self, key, resource, param): + is_different = False + + if param != resource['http'][key]: + is_different = True + + return is_different + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + uuid=dict(type='str'), + pool=dict(type='str'), + delay_s=dict(type='int'), + timeout_s=dict(type='int'), + up_threshold=dict(type='int'), + down_threshold=dict(type='int'), + type=dict(type='str'), + http=dict( + type='dict', + options=dict( + expected_codes=dict(type='list', elements='str'), + method=dict(type='str'), + url_path=dict(type='str'), + version=dict(type='str'), + host=dict(type='str'), + ) + ), + tags=dict(type='dict'), + state=dict(default='present', choices=ALLOWED_STATES), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=(), + required_one_of=(), + required_if=(), + supports_check_mode=True, + ) + + cloudscale_load_balancer_health_monitor = AnsibleCloudscaleLoadBalancerHealthMonitor(module) + cloudscale_load_balancer_health_monitor.query_constraint_keys = [] + + if module.params['state'] == "absent": + result = cloudscale_load_balancer_health_monitor.absent() + else: + result = cloudscale_load_balancer_health_monitor.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_listener.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_listener.py new file mode 100644 index 000000000..be91ac0b5 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_listener.py @@ -0,0 +1,271 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2023, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright: (c) 2023, Kenneth Joss <kenneth.joss@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: load_balancer_listener +short_description: Manages load balancer listeners on the cloudscale.ch IaaS service +description: + - Get, create, update, delete listeners on the cloudscale.ch IaaS service. +notes: + - If I(uuid) option is provided, it takes precedence over I(name) for load balancer listener selection. This allows to update the listener's name. + - If no I(uuid) option is provided, I(name) is used for load balancer listener selection. + - If more than one load balancer with this name exists, execution is aborted. +author: + - Gaudenz Steinlin (@gaudenz) + - Kenneth Joss (@k-304) +version_added: "2.3.0" +options: + state: + description: + - State of the load balancer listener. + choices: [ present, absent ] + default: present + type: str + name: + description: + - Name of the load balancer listener. + - Either I(name) or I(uuid) are required. + type: str + uuid: + description: + - UUID of the load balancer listener. + - Either I(name) or I(uuid) are required. + type: str + pool: + description: + - The pool of the listener. + type: str + protocol: + description: + - The protocol used for receiving traffic. + type: str + protocol_port: + description: + - The port on which traffic is received. + type: int + allowed_cidrs: + description: + - Restrict the allowed source IPs for this listener. + - Empty means that any source IP is allowed. If the list is non-empty, traffic from source IPs not included is denied. + type: list + elements: str + timeout_client_data_ms: + description: + - Client inactivity timeout in milliseconds. + type: int + timeout_member_connect_ms: + description: + - Pool member connection timeout in milliseconds. + type: int + timeout_member_data_ms: + description: + - Pool member inactivity timeout in milliseconds. + type: int + tags: + description: + - Tags assosiated with the load balancer. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create a load balancer listener for a pool using registered variables +- name: Create a load balancer pool + cloudscale_ch.cloud.load_balancer_pool: + name: 'swimming-pool' + load_balancer: '3d41b118-f95c-4897-ad74-2260fea783fc' + algorithm: 'round_robin' + protocol: 'tcp' + api_token: xxxxxx + register: load_balancer_pool + +- name: Create a load balancer listener + cloudscale_ch.cloud.load_balancer_listener: + name: 'swimming-pool-listener' + pool: '{{ load_balancer_pool.uuid }}' + protocol: 'tcp' + protocol_port: 8080 + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + +# Create a load balancer listener for a pool with restriction +- name: Create a load balancer listener with ip restriction + cloudscale_ch.cloud.load_balancer_listener: + name: 'new-listener2' + pool: '618a6cc8-d757-4fab-aa10-d49dc47e667b' + protocol: 'tcp' + protocol_port: 8080 + allowed_cidrs: + - '192.168.3.0/24' + - '2001:db8:85a3:8d3::/64' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + +# Get load balancer listener facts by name +- name: Get facts of a load balancer listener by name + cloudscale_ch.cloud.load_balancer_listener: + name: '{{ cloudscale_resource_prefix }}-test' + api_token: xxxxxx +''' + +RETURN = ''' +href: + description: API URL to get details about this load balancer lintener + returned: success when not state == absent + type: str + sample: https://api.cloudscale.ch/v1/load-balancers/listeners/9fa91f17-fdb4-431f-8a59-78473f64e661 +uuid: + description: The unique identifier for this load balancer listener + returned: success + type: str + sample: 9fa91f17-fdb4-431f-8a59-78473f64e661 +name: + description: The display name of the load balancer listener + returned: success + type: str + sample: new-listener +created_at: + description: The creation date and time of the load balancer listener + returned: success when not state == absent + type: str + sample: "2023-02-07T15:32:02.308041Z" +pool: + description: The pool of the load balancer listener + returned: success when not state == absent + type: complex + contains: + href: + description: API URL to get details about the pool. + returned: success + type: str + sample: https://api.cloudscale.ch/v1/load-balancers/pools/618a6cc8-d757-4fab-aa10-d49dc47e667b + uuid: + description: The unique identifier for the pool. + returned: success + type: str + sample: 618a6cc8-d757-4fab-aa10-d49dc47e667b + name: + description: The name of the pool. + returned: success + type: str + sample: new-listener +protocol: + description: The protocol used for receiving traffic + returned: success when not state == absent + type: str + sample: tcp +protocol_port: + description: The port on which traffic is received + returned: success when not state == absent + type: int + sample: 8080 +allowed_cidrs: + description: Restrict the allowed source IPs for this listener + returned: success when not state == absent + type: list + sample: ["192.168.3.0/24", "2001:db8:85a3:8d3::/64"] +timeout_client_data_ms: + description: Client inactivity timeout in milliseconds + returned: success when not state == absent + type: int + sample: 50000 +timeout_member_connect_ms: + description: Pool member connection timeout in milliseconds + returned: success when not state == absent + type: int + sample: 50000 +timeout_member_data_ms: + description: Pool member inactivity timeout in milliseconds + returned: success when not state == absent + type: int + sample: 50000 +tags: + description: Tags assosiated with the load balancer listener + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + +ALLOWED_STATES = ('present', + 'absent', + ) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(type='str'), + uuid=dict(type='str'), + pool=dict(type='str'), + protocol=dict(type='str'), + protocol_port=dict(type='int'), + allowed_cidrs=dict(type='list', elements='str'), + timeout_client_data_ms=dict(type='int'), + timeout_member_connect_ms=dict(type='int'), + timeout_member_data_ms=dict(type='int'), + tags=dict(type='dict'), + state=dict(type='str', default='present', choices=ALLOWED_STATES), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=(), + required_one_of=(('name', 'uuid'),), + required_if=(('state', 'present', ('name',),),), + supports_check_mode=True, + ) + + cloudscale_load_balancer_listener = AnsibleCloudscaleBase( + module, + resource_name='load-balancers/listeners', + resource_create_param_keys=[ + 'name', + 'pool', + 'protocol', + 'protocol_port', + 'allowed_cidrs', + 'timeout_client_data_ms', + 'timeout_member_connect_ms', + 'timeout_member_data_ms', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'allowed_cidrs', + 'timeout_client_data_ms', + 'timeout_member_connect_ms', + 'timeout_member_data_ms', + 'tags', + ], + ) + + if module.params['state'] == "absent": + result = cloudscale_load_balancer_listener.absent() + else: + result = cloudscale_load_balancer_listener.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_pool.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_pool.py new file mode 100644 index 000000000..6b794a3e3 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_pool.py @@ -0,0 +1,217 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2023, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright: (c) 2023, Kenneth Joss <kenneth.joss@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: load_balancer_pool +short_description: Manages load balancer pools on the cloudscale.ch IaaS service +description: + - Get, create, update, delete pools on the cloudscale.ch IaaS service. +notes: + - If I(uuid) option is provided, it takes precedence over I(name) for pool selection. This allows to update the load balancer pool's name. + - If no I(uuid) option is provided, I(name) is used for pool selection. If more than one pool with this name exists, execution is aborted. +author: + - Gaudenz Steinlin (@gaudenz) + - Kenneth Joss (@k-304) +version_added: "2.3.0" +options: + state: + description: + - State of the load balancer pool. + choices: [ present, absent ] + default: present + type: str + name: + description: + - Name of the load balancer pool. + type: str + uuid: + description: + - UUID of the load balancer pool. + - Either I(name) or I(uuid) are required. + type: str + load_balancer: + description: + - UUID of the load balancer for this pool. + type: str + algorithm: + description: + - The algorithm according to which the incoming traffic is distributed between the pool members. + - See the [API documentation](https://www.cloudscale.ch/en/api/v1#pool-algorithms) for supported distribution algorithms. + type: str + protocol: + description: + - The protocol used for traffic between the load balancer and the pool members. + - See the [API documentation](https://www.cloudscale.ch/en/api/v1#pool-protocols) for supported protocols. + type: str + tags: + description: + - Tags assosiated with the load balancer. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create a pool for a load balancer using registered variables +- name: Create a running load balancer + cloudscale_ch.cloud.load_balancer: + name: 'lb1' + flavor: 'lb-standard' + zone: 'lpg1' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + register: load_balancer + +- name: Create a load balancer pool + cloudscale_ch.cloud.load_balancer_pool: + name: 'swimming-pool' + load_balancer: '{{ load_balancer.uuid }}' + algorithm: 'round_robin' + protocol: 'tcp' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + register: load_balancer_pool + +# Create a load balancer pool with algorithm: round_robin and protocol: tcp +- name: Create a load balancer pool + cloudscale_ch.cloud.load_balancer_pool: + name: 'cloudscale-loadbalancer-pool1' + load_balancer: '3766c579-3012-4a85-8192-2bbb4ef85b5f' + algorithm: 'round_robin' + protocol: 'tcp' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + +# Get load balancer pool facts by name +- name: Get facts of a load balancer pool + cloudscale_ch.cloud.load_balancer_pool: + name: cloudscale-loadbalancer-pool1 + api_token: xxxxxx +''' + +RETURN = ''' +href: + description: API URL to get details about this load balancer + returned: success when not state == absent + type: str + sample: https://api.cloudscale.ch/v1/load-balancers/pools/ +uuid: + description: The unique identifier for this load balancer pool + returned: success + type: str + sample: 3766c579-3012-4a85-8192-2bbb4ef85b5f +name: + description: The display name of the load balancer pool + returned: success + type: str + sample: web-lb-pool1 +created_at: + description: The creation date and time of the load balancer pool + returned: success when not state == absent + type: str + sample: "2023-02-07T15:32:02.308041Z" +load_balancer: + description: The load balancer this pool is connected to + returned: success when not state == absent + type: list + sample: { + "href": "https://api.cloudscale.ch/v1/load-balancers/15264769-ac69-4809-a8e4-4d73f8f92496", + "uuid": "15264769-ac69-4809-a8e4-4d73f8f92496", + "name": "web-lb" + } +algorithm: + description: The algorithm according to which the incoming traffic is distributed between the pool members + returned: success + type: str + sample: round_robin +protocol: + description: The protocol used for traffic between the load balancer and the pool members + returned: success + type: str + sample: tcp +state: + description: The current state of the load balancer pool + returned: success + type: str + sample: present +tags: + description: Tags assosiated with the load balancer + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + +ALLOWED_STATES = ('present', + 'absent', + ) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(), + uuid=dict(), + load_balancer=dict(), + algorithm=dict(type='str'), + protocol=dict(type='str'), + tags=dict(type='dict'), + state=dict(default='present', choices=ALLOWED_STATES), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=(), + required_one_of=(('name', 'uuid'),), + required_if=(('state', 'present', ('name',),),), + supports_check_mode=True, + ) + + cloudscale_load_balancer_pool = AnsibleCloudscaleBase( + module, + resource_name='load-balancers/pools', + resource_create_param_keys=[ + 'name', + 'load_balancer', + 'algorithm', + 'protocol', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'tags', + ], + ) + cloudscale_load_balancer_pool.query_constraint_keys = [] + + if module.params['state'] == "absent": + result = cloudscale_load_balancer_pool.absent() + else: + result = cloudscale_load_balancer_pool.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_pool_member.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_pool_member.py new file mode 100644 index 000000000..49a2124d9 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/load_balancer_pool_member.py @@ -0,0 +1,319 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2023, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright: (c) 2023, Kenneth Joss <kenneth.joss@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: load_balancer_pool_member +short_description: Manages load balancer pool members on the cloudscale.ch IaaS service +description: + - Get, create, update, delete pool members on the cloudscale.ch IaaS service. +notes: + - If I(uuid) option is provided, it takes precedence over I(name) for pool member selection. This allows to update the member's name. + - If no I(uuid) option is provided, I(name) is used for pool member selection. If more than one load balancer with this name exists, execution is aborted. +author: + - Gaudenz Steinlin (@gaudenz) + - Kenneth Joss (@k-304) +version_added: "2.3.0" +options: + state: + description: + - State of the load balancer pool member. + choices: [ present, absent ] + default: present + type: str + name: + description: + - Name of the load balancer pool member. + - Either I(name) or I(uuid) are required. + type: str + uuid: + description: + - UUID of the load balancer. + - Either I(name) or I(uuid) are required. + type: str + load_balancer_pool: + description: + - UUID of the load balancer pool. + type: str + enabled: + description: + - Pool member will not receive traffic if false. Default is true. + default: true + type: bool + protocol_port: + description: + - The port to which actual traffic is sent. + type: int + monitor_port: + description: + - The port to which health monitor checks are sent. + - If not specified, protocol_port will be used. Default is null. + default: null + type: int + address: + description: + - The IP address to which traffic is sent. + type: str + subnet: + description: + - The subnet of the address must be specified here. + type: str + tags: + description: + - Tags assosiated with the load balancer. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create a pool member for a load balancer pool using registered variables +- name: Create a load balancer pool + cloudscale_ch.cloud.load_balancer_pool: + name: 'swimming-pool' + load_balancer: '514064c2-cfd4-4b0c-8a4b-c68c552ff84f' + algorithm: 'round_robin' + protocol: 'tcp' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + register: load_balancer_pool + +- name: Create a load balancer pool member + cloudscale_ch.cloud.load_balancer_pool_member: + name: 'my-shiny-swimming-pool-member' + load_balancer_pool: '{{ load_balancer_pool.uuid }}' + enabled: true + protocol_port: 8080 + monitor_port: 8081 + subnet: '70d282ab-2a01-4abb-ada5-34e56a5a7eee' + address: '172.16.0.100' + tags: + project: ansible-test + stage: production + sla: 24-7 + api_token: xxxxxx + +# Get load balancer pool member facts by name +- name: Get facts of a load balancer pool member by name + cloudscale_ch.cloud.load_balancer_pool_member: + name: 'my-shiny-swimming-pool-member' + api_token: xxxxxx +''' + +RETURN = ''' +href: + description: API URL to get details about this load balancer + returned: success when not state == absent + type: str + sample: https://api.cloudscale.ch/v1/load-balancers/pools/20a7eb11-3e17-4177-b46d-36e13b101d1c/members/b9991773-857d-47f6-b20b-0a03709529a9 +uuid: + description: The unique identifier for this load balancer pool member + returned: success + type: str + sample: cfde831a-4e87-4a75-960f-89b0148aa2cc +name: + description: The display name of the load balancer pool member + returned: success + type: str + sample: web-lb-pool +enabled: + description: THe status of the load balancer pool member + returned: success + type: bool + sample: true +created_at: + description: The creation date and time of the load balancer pool member + returned: success when not state == absent + type: str + sample: "2023-02-07T15:32:02.308041Z" +pool: + description: The pool of the pool member + returned: success + type: dict + sample: { + "href": "https://api.cloudscale.ch/v1/load-balancers/pools/20a7eb11-3e17-4177-b46d-36e13b101d1c", + "uuid": "20a7eb11-3e17-4177-b46d-36e13b101d1c", + "name": "web-lb-pool" + } +protocol_port: + description: The port to which actual traffic is sent + returned: success + type: int + sample: 8080 +monitor_port: + description: The port to which health monitor checks are sent + returned: success + type: int + sample: 8081 +address: + description: The IP address to which traffic is sent + returned: success + type: str + sample: 10.11.12.3 +subnet: + description: The subnet in a private network in which address is located + returned: success + type: dict + sample: { + "href": "https://api.cloudscale.ch/v1/subnets/70d282ab-2a01-4abb-ada5-34e56a5a7eee", + "uuid": "70d282ab-2a01-4abb-ada5-34e56a5a7eee", + "cidr": "10.11.12.0/24" + } +monitor_status: + description: The status of the pool's health monitor check for this member + returned: success + type: str + sample: up +tags: + description: Tags assosiated with the load balancer + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + +ALLOWED_STATES = ('present', + 'absent', + ) + + +class AnsibleCloudscaleLoadBalancerPoolMember(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleLoadBalancerPoolMember, self).__init__( + module, + resource_name='load-balancers/pools/%s/members' % module.params['load_balancer_pool'], + resource_create_param_keys=[ + 'name', + 'enabled', + 'protocol_port', + 'monitor_port', + 'address', + 'subnet', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'enabled', + 'tags', + ], + ) + + def query(self): + # Initialize + self._resource_data = self.init_resource() + + # Query by UUID + uuid = self._module.params[self.resource_key_uuid] + if uuid is not None: + + # network id case + if "/" in uuid: + uuid = uuid.split("/")[0] + + resource = self._get('%s/%s' % (self.resource_name, uuid)) + if resource: + self._resource_data = resource + self._resource_data['state'] = "present" + + # Query by name + else: + name = self._module.params[self.resource_key_name] + + # Resource has no name field, we use a defined tag as name + if self.use_tag_for_name: + resources = self._get('%s?tag:%s=%s' % (self.resource_name, self.resource_name_tag, name)) + else: + resources = self._get('%s' % self.resource_name) + + matching = [] + if resources is None: + self._module.fail_json( + msg="The load balancer pool %s does not exist." + % (self.resource_name,) + ) + for resource in resources: + if self.use_tag_for_name: + resource[self.resource_key_name] = resource['tags'].get(self.resource_name_tag) + + # Skip resource if constraints is not given e.g. in case of floating_ip the ip_version differs + for constraint_key in self.query_constraint_keys: + if self._module.params[constraint_key] is not None: + if constraint_key == 'zone': + resource_value = resource['zone']['slug'] + else: + resource_value = resource[constraint_key] + + if resource_value != self._module.params[constraint_key]: + break + else: + if resource[self.resource_key_name] == name: + matching.append(resource) + + # Fail on more than one resource with identical name + if len(matching) > 1: + self._module.fail_json( + msg="More than one %s resource with '%s' exists: %s. " + "Use the '%s' parameter to identify the resource." % ( + self.resource_name, + self.resource_key_name, + name, + self.resource_key_uuid + ) + ) + elif len(matching) == 1: + self._resource_data = matching[0] + self._resource_data['state'] = "present" + + return self.pre_transform(self._resource_data) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(), + uuid=dict(), + load_balancer_pool=dict(type='str'), + enabled=dict(type='bool', default=True), + protocol_port=dict(type='int'), + monitor_port=dict(type='int'), + subnet=dict(type='str'), + address=dict(type='str'), + tags=dict(type='dict'), + state=dict(default='present', choices=ALLOWED_STATES), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=(), + required_one_of=(('name', 'uuid'),), + supports_check_mode=True, + ) + + cloudscale_load_balancer_pool_member = AnsibleCloudscaleLoadBalancerPoolMember(module) + cloudscale_load_balancer_pool_member.query_constraint_keys = [] + + if module.params['state'] == "absent": + result = cloudscale_load_balancer_pool_member.absent() + else: + result = cloudscale_load_balancer_pool_member.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/network.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/network.py new file mode 100644 index 000000000..7b1da5b2e --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/network.py @@ -0,0 +1,197 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, René Moser <mail@renemoser.net> +# 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 + + +DOCUMENTATION = ''' +--- +module: network +short_description: Manages networks on the cloudscale.ch IaaS service +description: + - Create, update and remove networks. +author: + - René Moser (@resmo) +version_added: "1.2.0" +options: + name: + description: + - Name of the network. + - Either I(name) or I(uuid) is required. + type: str + uuid: + description: + - UUID of the network. + - Either I(name) or I(uuid) is required. + type: str + mtu: + description: + - The MTU of the network. + default: 9000 + type: int + auto_create_ipv4_subnet: + description: + - Whether to automatically create an IPv4 subnet in the network or not. + default: true + type: bool + zone: + description: + - Zone slug of the network (e.g. C(lpg1) or C(rma1)). + type: str + state: + description: + - State of the network. + choices: [ present, absent ] + default: present + type: str + tags: + description: + - Tags assosiated with the networks. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +--- +- name: Ensure network exists + cloudscale_ch.cloud.network: + name: my network + api_token: xxxxxx + +- name: Ensure network in a specific zone + cloudscale_ch.cloud.network: + name: my network + zone: lpg1 + api_token: xxxxxx + +- name: Ensure a network is absent + cloudscale_ch.cloud.network: + name: my network + state: absent + api_token: xxxxxx +''' + +RETURN = ''' +--- +href: + description: API URL to get details about this network. + returned: success + type: str + sample: https://api.cloudscale.ch/v1/networks/cfde831a-4e87-4a75-960f-89b0148aa2cc +uuid: + description: The unique identifier for the network. + returned: success + type: str + sample: cfde831a-4e87-4a75-960f-89b0148aa2cc +name: + description: The name of the network. + returned: success + type: str + sample: my network +created_at: + description: The creation date and time of the network. + returned: success + type: str + sample: "2019-05-29T13:18:42.511407Z" +subnets: + description: A list of subnets objects of the network. + returned: success + type: complex + contains: + href: + description: API URL to get details about the subnet. + returned: success + type: str + sample: https://api.cloudscale.ch/v1/subnets/33333333-1864-4608-853a-0771b6885a3 + uuid: + description: The unique identifier for the subnet. + returned: success + type: str + sample: 33333333-1864-4608-853a-0771b6885a3 + cidr: + description: The CIDR of the subnet. + returned: success + type: str + sample: 172.16.0.0/24 +mtu: + description: The MTU of the network. + returned: success + type: int + sample: 9000 +zone: + description: The zone of the network. + returned: success + type: dict + sample: { 'slug': 'rma1' } +state: + description: State of the network. + returned: success + type: str + sample: present +tags: + description: Tags assosiated with the network. + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(type='str'), + uuid=dict(type='str'), + mtu=dict(type='int', default=9000), + auto_create_ipv4_subnet=dict(type='bool', default=True), + zone=dict(type='str'), + tags=dict(type='dict'), + state=dict(default='present', choices=['absent', 'present']), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('name', 'uuid'),), + required_if=(('state', 'present', ('name',),),), + supports_check_mode=True, + ) + + cloudscale_network = AnsibleCloudscaleBase( + module, + resource_name='networks', + resource_create_param_keys=[ + 'name', + 'mtu', + 'auto_create_ipv4_subnet', + 'zone', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'mtu', + 'tags', + ], + ) + + cloudscale_network.query_constraint_keys = [ + 'zone', + ] + + if module.params['state'] == 'absent': + result = cloudscale_network.absent() + else: + result = cloudscale_network.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/objects_user.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/objects_user.py new file mode 100644 index 000000000..d4d117817 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/objects_user.py @@ -0,0 +1,161 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, René Moser <mail@renemoser.net> +# 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 + + +DOCUMENTATION = r''' +--- +module: objects_user +short_description: Manages objects users on the cloudscale.ch IaaS service +description: + - Create, update and remove objects users cloudscale.ch IaaS service. +author: + - Rene Moser (@resmo) +version_added: 1.1.0 +options: + display_name: + description: + - Display name of the objects user. + - Either I(display_name) or I(id) is required. + type: str + aliases: + - name + id: + description: + - Name of the objects user. + - Either I(display_name) or I(id) is required. + type: str + tags: + description: + - Tags associated with the objects user. Set this to C({}) to clear any tags. + type: dict + state: + description: + - State of the objects user. + default: present + choices: [ present, absent ] + type: str +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = r''' +- name: Create an objects user + cloudscale_ch.cloud.objects_user: + display_name: alan + tags: + project: luna + api_token: xxxxxx + register: object_user + +- name: print keys + debug: + var: object_user.keys + +- name: Update an objects user + cloudscale_ch.cloud.objects_user: + display_name: alan + tags: + project: gemini + api_token: xxxxxx + +- name: Remove an objects user + cloudscale_ch.cloud.objects_user: + display_name: alan + state: absent + api_token: xxxxxx +''' + +RETURN = r''' +href: + description: The API URL to get details about this resource. + returned: success when state == present + type: str + sample: https://api.cloudscale.ch/v1/objects-users/6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15 +display_name: + description: The display name of the objects user. + returned: success + type: str + sample: alan +id: + description: The ID of the objects user. + returned: success + type: str + sample: 6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15 +keys: + description: List of key objects. + returned: success + type: complex + contains: + access_key: + description: The access key. + returned: success + type: str + sample: 0ZTAIBKSGYBRHQ09G11W + secret_key: + description: The secret key. + returned: success + type: str + sample: bn2ufcwbIa0ARLc5CLRSlVaCfFxPHOpHmjKiH34T +tags: + description: Tags assosiated with the objects user. + returned: success + type: dict + sample: { 'project': 'my project' } +state: + description: The current status of the objects user. + returned: success + type: str + sample: present +''' + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.api import AnsibleCloudscaleBase, cloudscale_argument_spec + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + display_name=dict(type='str', aliases=['name']), + id=dict(type='str'), + tags=dict(type='dict'), + state=dict(type='str', default='present', choices=('present', 'absent')), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('display_name', 'id'),), + required_if=(('state', 'present', ('display_name',),),), + supports_check_mode=True, + ) + + cloudscale_objects_user = AnsibleCloudscaleBase( + module, + resource_name='objects-users', + resource_key_uuid='id', + resource_key_name='display_name', + resource_create_param_keys=[ + 'display_name', + 'tags', + ], + resource_update_param_keys=[ + 'display_name', + 'tags', + ], + ) + + if module.params['state'] == "absent": + result = cloudscale_objects_user.absent() + else: + result = cloudscale_objects_user.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/server.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/server.py new file mode 100644 index 000000000..d912750c1 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/server.py @@ -0,0 +1,737 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2017, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright: (c) 2019, René Moser <mail@renemoser.net> +# 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 + + +DOCUMENTATION = ''' +--- +module: server +short_description: Manages servers on the cloudscale.ch IaaS service +description: + - Create, update, start, stop and delete servers on the cloudscale.ch IaaS service. +notes: + - If I(uuid) option is provided, it takes precedence over I(name) for server selection. This allows to update the server's name. + - If no I(uuid) option is provided, I(name) is used for server selection. If more than one server with this name exists, execution is aborted. + - Only the I(name) and I(flavor) are evaluated for the update. + - The option I(force=true) must be given to allow the reboot of existing running servers for applying the changes. +author: + - Gaudenz Steinlin (@gaudenz) + - René Moser (@resmo) + - Denis Krienbühl (@href) +version_added: "1.0.0" +options: + state: + description: + - State of the server. + choices: [ running, stopped, absent ] + default: running + type: str + name: + description: + - Name of the Server. + - Either I(name) or I(uuid) are required. + type: str + uuid: + description: + - UUID of the server. + - Either I(name) or I(uuid) are required. + type: str + flavor: + description: + - Flavor of the server. + type: str + image: + description: + - Image used to create the server. + type: str + zone: + description: + - Zone in which the server resides (e.g. C(lpg1) or C(rma1)). + type: str + volume_size_gb: + description: + - Size of the root volume in GB. + default: 10 + type: int + bulk_volume_size_gb: + description: + - Size of the bulk storage volume in GB. + - No bulk storage volume if not set. + type: int + ssh_keys: + description: + - List of SSH public keys. + - Use the full content of your .pub file here. + type: list + elements: str + password: + description: + - Password for the server. + type: str + use_public_network: + description: + - Attach a public network interface to the server. + type: bool + use_private_network: + description: + - Attach a private network interface to the server. + type: bool + use_ipv6: + description: + - Enable IPv6 on the public network interface. + default: true + type: bool + interfaces: + description: + - List of network interface objects specifying the interfaces to be attached to the server. + See U(https://www.cloudscale.ch/en/api/v1/#interfaces-attribute-specification) for more details. + type: list + elements: dict + version_added: 1.4.0 + suboptions: + network: + description: + - Create a network interface on the network identified by UUID. + Use 'public' instead of an UUID to attach a public network interface. + Can be omitted if a subnet is provided under addresses. + type: str + addresses: + description: + - Attach a private network interface and configure a subnet and/or an IP address. + type: list + elements: dict + suboptions: + subnet: + description: + - UUID of the subnet from which an address will be assigned. + type: str + address: + description: + - The static IP address of the interface. Use '[]' to avoid assigning an IP address via DHCP. + type: str + server_groups: + description: + - List of UUID or names of server groups. + type: list + elements: str + user_data: + description: + - Cloud-init configuration (cloud-config) data to use for the server. + type: str + force: + description: + - Allow to stop the running server for updating if necessary. + default: false + type: bool + tags: + description: + - Tags assosiated with the servers. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create and start a server with an existing server group (shiny-group) +- name: Start cloudscale.ch server + cloudscale_ch.cloud.server: + name: my-shiny-cloudscale-server + image: debian-10 + flavor: flex-4-4 + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + server_groups: shiny-group + zone: lpg1 + use_private_network: true + bulk_volume_size_gb: 100 + api_token: xxxxxx + +# Start another server in anti-affinity (server group shiny-group) +- name: Start second cloudscale.ch server + cloudscale_ch.cloud.server: + name: my-other-shiny-server + image: ubuntu-16.04 + flavor: flex-8-2 + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + server_groups: shiny-group + zone: lpg1 + api_token: xxxxxx + +# Force to update the flavor of a running server +- name: Start cloudscale.ch server + cloudscale_ch.cloud.server: + name: my-shiny-cloudscale-server + image: debian-10 + flavor: flex-8-2 + force: true + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + use_private_network: true + bulk_volume_size_gb: 100 + api_token: xxxxxx + register: server1 + +# Stop the first server +- name: Stop my first server + cloudscale_ch.cloud.server: + uuid: '{{ server1.uuid }}' + state: stopped + api_token: xxxxxx + +# Delete my second server +- name: Delete my second server + cloudscale_ch.cloud.server: + name: my-other-shiny-server + state: absent + api_token: xxxxxx + +# Start a server and wait for the SSH host keys to be generated +- name: Start server and wait for SSH host keys + cloudscale_ch.cloud.server: + name: my-cloudscale-server-with-ssh-key + image: debian-10 + flavor: flex-4-2 + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + api_token: xxxxxx + register: server + until: server is not failed + retries: 5 + delay: 2 + +# Start a server with two network interfaces: +# +# A public interface with IPv4/IPv6 +# A private interface on a specific private network with an IPv4 address + +- name: Start a server with a public and private network interface + cloudscale_ch.cloud.server: + name: my-cloudscale-server-with-two-network-interfaces + image: debian-10 + flavor: flex-4-2 + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + api_token: xxxxxx + interfaces: + - network: 'public' + - addresses: + - subnet: UUID_of_private_subnet + +# Start a server with a specific IPv4 address from subnet range +- name: Start a server with a specific IPv4 address from subnet range + cloudscale_ch.cloud.server: + name: my-cloudscale-server-with-specific-address + image: debian-10 + flavor: flex-4-2 + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + api_token: xxxxxx + interfaces: + - addresses: + - subnet: UUID_of_private_subnet + address: 'A.B.C.D' + +# Start a server with two network interfaces: +# +# A public interface with IPv4/IPv6 +# A private interface on a specific private network with no IPv4 address + +- name: Start a server with a private network interface and no IP address + cloudscale_ch.cloud.server: + name: my-cloudscale-server-with-specific-address + image: debian-10 + flavor: flex-4-2 + ssh_keys: + - ssh-rsa XXXXXXXXXX...XXXX ansible@cloudscale + api_token: xxxxxx + interfaces: + - network: 'public' + - network: UUID_of_private_network + addresses: [] +''' + +RETURN = ''' +href: + description: API URL to get details about this server + returned: success when not state == absent + type: str + sample: https://api.cloudscale.ch/v1/servers/cfde831a-4e87-4a75-960f-89b0148aa2cc +uuid: + description: The unique identifier for this server + returned: success + type: str + sample: cfde831a-4e87-4a75-960f-89b0148aa2cc +name: + description: The display name of the server + returned: success + type: str + sample: its-a-me-mario.cloudscale.ch +state: + description: The current status of the server + returned: success + type: str + sample: running +flavor: + description: The flavor that has been used for this server + returned: success when not state == absent + type: dict + sample: { "slug": "flex-4-2", "name": "Flex-4-2", "vcpu_count": 2, "memory_gb": 4 } +image: + description: The image used for booting this server + returned: success when not state == absent + type: dict + sample: { "default_username": "ubuntu", "name": "Ubuntu 18.04 LTS", "operating_system": "Ubuntu", "slug": "ubuntu-18.04" } +zone: + description: The zone used for booting this server + returned: success when not state == absent + type: dict + sample: { 'slug': 'lpg1' } +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": [ ... ] } ] +ssh_fingerprints: + description: A list of SSH host key fingerprints. Will be null until the host keys could be retrieved from the server. + returned: success when not state == absent + type: list + sample: ["ecdsa-sha2-nistp256 SHA256:XXXX", ... ] +ssh_host_keys: + description: A list of SSH host keys. Will be null until the host keys could be retrieved from the server. + returned: success when not state == absent + type: list + sample: ["ecdsa-sha2-nistp256 XXXXX", ... ] +server_groups: + description: List of server groups + returned: success when not state == absent + type: list + sample: [ {"href": "https://api.cloudscale.ch/v1/server-groups/...", "uuid": "...", "name": "db-group"} ] +tags: + description: Tags assosiated with the server. + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from datetime import datetime, timedelta +from time import sleep +from copy import deepcopy + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + +ALLOWED_STATES = ('running', + 'stopped', + 'absent', + ) + + +class AnsibleCloudscaleServer(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleServer, self).__init__(module) + + # Initialize server dictionary + self._info = {} + + def _init_server_container(self): + return { + 'uuid': self._module.params.get('uuid') or self._info.get('uuid'), + 'name': self._module.params.get('name') or self._info.get('name'), + 'state': 'absent', + } + + def _get_server_info(self, refresh=False): + if self._info and not refresh: + return self._info + + self._info = self._init_server_container() + + uuid = self._info.get('uuid') + if uuid is not None: + server_info = self._get('servers/%s' % uuid) + if server_info: + self._info = self._transform_state(server_info) + + else: + name = self._info.get('name') + if name is not None: + servers = self._get('servers') or [] + matching_server = [] + for server in servers: + if server['name'] == name: + matching_server.append(server) + + 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) + + return self._info + + @staticmethod + def _transform_state(server): + if 'status' in server: + server['state'] = server['status'] + del server['status'] + else: + server['state'] = 'absent' + return server + + def _wait_for_state(self, states): + start = datetime.now() + timeout = self._module.params['api_timeout'] * 2 + while datetime.now() - start < timedelta(seconds=timeout): + server_info = self._get_server_info(refresh=True) + if server_info.get('state') in states: + return server_info + sleep(1) + + # Timeout succeeded + if server_info.get('name') is not None: + msg = "Timeout while waiting for a state change on server %s to states %s. " \ + "Current state is %s." % (server_info.get('name'), states, server_info.get('state')) + else: + name_uuid = self._module.params.get('name') or self._module.params.get('uuid') + msg = 'Timeout while waiting to find the server %s' % name_uuid + + self._module.fail_json(msg=msg) + + def _start_stop_server(self, server_info, target_state="running", ignore_diff=False): + actions = { + 'stopped': 'stop', + 'running': 'start', + } + + server_state = server_info.get('state') + if server_state != target_state: + self._result['changed'] = True + + if not ignore_diff: + self._result['diff']['before'].update({ + 'state': server_info.get('state'), + }) + self._result['diff']['after'].update({ + 'state': target_state, + }) + if not self._module.check_mode: + self._post('servers/%s/%s' % (server_info['uuid'], actions[target_state])) + server_info = self._wait_for_state((target_state, )) + + return server_info + + def _update_param(self, param_key, server_info, requires_stop=False): + param_value = self._module.params.get(param_key) + if param_value is None: + return server_info + + if 'slug' in server_info[param_key]: + server_v = server_info[param_key]['slug'] + else: + server_v = server_info[param_key] + + if server_v != param_value: + # Set the diff output + self._result['diff']['before'].update({param_key: server_v}) + self._result['diff']['after'].update({param_key: param_value}) + + if server_info.get('state') == "running": + if requires_stop and not self._module.params.get('force'): + self._module.warn("Some changes won't be applied to running servers. " + "Use force=true to allow the server '%s' to be stopped/started." % server_info['name']) + return server_info + + # Either the server is stopped or change is forced + self._result['changed'] = True + if not self._module.check_mode: + + if requires_stop: + self._start_stop_server(server_info, target_state="stopped", ignore_diff=True) + + patch_data = { + param_key: param_value, + } + + # Response is 204: No Content + self._patch('servers/%s' % server_info['uuid'], patch_data) + + # State changes to "changing" after update, waiting for stopped/running + server_info = self._wait_for_state(('stopped', 'running')) + + return server_info + + def _get_server_group_ids(self): + server_group_params = self._module.params['server_groups'] + if not server_group_params: + return None + + matching_group_names = [] + results = [] + server_groups = self._get('server-groups') + for server_group in server_groups: + if server_group['uuid'] in server_group_params: + results.append(server_group['uuid']) + server_group_params.remove(server_group['uuid']) + + elif server_group['name'] in server_group_params: + results.append(server_group['uuid']) + server_group_params.remove(server_group['name']) + # Remember the names found + matching_group_names.append(server_group['name']) + + # Names are not unique, verify if name already found in previous iterations + elif server_group['name'] in matching_group_names: + self._module.fail_json(msg="More than one server group with name exists: '%s'. " + "Use the 'uuid' parameter to identify the server group." % server_group['name']) + + if server_group_params: + self._module.fail_json(msg="Server group name or UUID not found: %s" % ', '.join(server_group_params)) + + return results + + def _create_server(self, server_info): + self._result['changed'] = True + self.normalize_interfaces_param() + + data = deepcopy(self._module.params) + for i in ('uuid', 'state', 'force', 'api_timeout', 'api_token', 'api_url'): + del data[i] + data['server_groups'] = self._get_server_group_ids() + + self._result['diff']['before'] = self._init_server_container() + self._result['diff']['after'] = deepcopy(data) + if not self._module.check_mode: + self._post('servers', data) + server_info = self._wait_for_state(('running', )) + return server_info + + def _update_server(self, server_info): + + previous_state = server_info.get('state') + + # The API doesn't support to update server groups. + # Show a warning to the user if the desired state does not match. + desired_server_group_ids = self._get_server_group_ids() + if desired_server_group_ids is not None: + current_server_group_ids = [grp['uuid'] for grp in server_info['server_groups']] + if desired_server_group_ids != current_server_group_ids: + self._module.warn("Server groups can not be mutated, server needs redeployment to change groups.") + + # Remove interface properties that were not filled out by the user + self.normalize_interfaces_param() + + # Compare the interfaces as specified by the user, with the interfaces + # as received by the API. The structures are somewhat different, so + # they need to be evaluated in detail + wanted = self._module.params.get('interfaces') + actual = server_info.get('interfaces') + + try: + update_interfaces = not self.has_wanted_interfaces(wanted, actual) + except KeyError as e: + self._module.fail_json( + msg="Error checking 'interfaces', missing key: %s" % e.args[0]) + + if update_interfaces: + server_info = self._update_param('interfaces', server_info) + + if not self._result['changed']: + self._result['changed'] = server_info['interfaces'] != actual + + server_info = self._update_param('flavor', server_info, requires_stop=True) + server_info = self._update_param('name', server_info) + server_info = self._update_param('tags', server_info) + + if previous_state == "running": + server_info = self._start_stop_server(server_info, target_state="running", ignore_diff=True) + + return server_info + + def present_server(self): + server_info = self._get_server_info() + + if server_info.get('state') != "absent": + + # If target state is stopped, stop before an potential update and force would not be required + if self._module.params.get('state') == "stopped": + server_info = self._start_stop_server(server_info, target_state="stopped") + + server_info = self._update_server(server_info) + + if self._module.params.get('state') == "running": + server_info = self._start_stop_server(server_info, target_state="running") + else: + server_info = self._create_server(server_info) + server_info = self._start_stop_server(server_info, target_state=self._module.params.get('state')) + + return server_info + + def absent_server(self): + server_info = self._get_server_info() + if server_info.get('state') != "absent": + self._result['changed'] = True + self._result['diff']['before'] = deepcopy(server_info) + self._result['diff']['after'] = self._init_server_container() + if not self._module.check_mode: + self._delete('servers/%s' % server_info['uuid']) + server_info = self._wait_for_state(('absent', )) + return server_info + + def has_wanted_interfaces(self, wanted, actual): + """ Compares the interfaces as specified by the user, with the + interfaces as reported by the server. + + """ + + if len(wanted or ()) != len(actual or ()): + return False + + def match_interface(spec): + + # First, find the interface that belongs to the spec + for interface in actual: + + # If we have a public network, only look for the right type + if spec.get('network') == 'public': + if interface['type'] == 'public': + break + + # If we have a private network, check the network's UUID + if spec.get('network') is not None: + if interface['type'] == 'private': + if interface['network']['uuid'] == spec['network']: + break + + # If we only have an addresses block, match all subnet UUIDs + wanted_subnet_ids = set( + a['subnet'] for a in (spec.get('addresses') or ())) + + actual_subnet_ids = set( + a['subnet']['uuid'] for a in interface['addresses']) + + if wanted_subnet_ids == actual_subnet_ids: + break + else: + return False # looped through everything without match + + # Fail if any of the addresses don't match + for wanted_addr in (spec.get('addresses') or ()): + + # Unspecified, skip + if 'address' not in wanted_addr: + continue + + addresses = set(a['address'] for a in interface['addresses']) + if wanted_addr['address'] not in addresses: + return False + + # If the wanted address is an empty list, but the actual list is + # not, the user wants to remove automatically set addresses + if spec.get('addresses') == [] and interface['addresses'] != []: + return False + + if interface['addresses'] == [] and spec.get('addresses') != []: + return False + + return interface + + for spec in wanted: + + # If there is any interface that does not match, clearly not all + # wanted interfaces are present + if not match_interface(spec): + return False + + return True + + def normalize_interfaces_param(self): + """ Goes through the interfaces parameter and gets it ready to be + sent to the API. """ + + for spec in (self._module.params.get('interfaces') or ()): + if spec['addresses'] is None: + del spec['addresses'] + if spec['network'] is None: + del spec['network'] + + for address in (spec.get('addresses') or ()): + if address['address'] is None: + del address['address'] + if address['subnet'] is None: + del address['subnet'] + + +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(), + zone=dict(), + volume_size_gb=dict(type='int', default=10), + bulk_volume_size_gb=dict(type='int'), + ssh_keys=dict(type='list', elements='str', no_log=False), + password=dict(no_log=True), + use_public_network=dict(type='bool'), + use_private_network=dict(type='bool'), + use_ipv6=dict(type='bool', default=True), + interfaces=dict( + type='list', + elements='dict', + options=dict( + network=dict(type='str'), + addresses=dict( + type='list', + elements='dict', + options=dict( + address=dict(type='str'), + subnet=dict(type='str'), + ), + ), + ), + ), + server_groups=dict(type='list', elements='str'), + user_data=dict(), + force=dict(type='bool', default=False), + tags=dict(type='dict'), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=( + ['interfaces', 'use_public_network'], + ['interfaces', 'use_private_network'], + ), + required_one_of=(('name', 'uuid'),), + supports_check_mode=True, + ) + + cloudscale_server = AnsibleCloudscaleServer(module) + if module.params['state'] == "absent": + server = cloudscale_server.absent_server() + else: + server = cloudscale_server.present_server() + + result = cloudscale_server.get_result(server) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/server_group.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/server_group.py new file mode 100644 index 000000000..f4dc9c319 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/server_group.py @@ -0,0 +1,171 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019, René Moser <mail@renemoser.net> +# 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 + + +DOCUMENTATION = ''' +--- +module: server_group +short_description: Manages server groups on the cloudscale.ch IaaS service +description: + - Create, update and remove server groups. +author: + - René Moser (@resmo) + - Denis Krienbühl (@href) +version_added: "1.0.0" +options: + name: + description: + - Name of the server group. + - Either I(name) or I(uuid) is required. These options are mutually exclusive. + type: str + uuid: + description: + - UUID of the server group. + - Either I(name) or I(uuid) is required. These options are mutually exclusive. + type: str + type: + description: + - Type of the server group. + default: anti-affinity + type: str + zone: + description: + - Zone slug of the server group (e.g. C(lpg1) or C(rma1)). + type: str + state: + description: + - State of the server group. + choices: [ present, absent ] + default: present + type: str + tags: + description: + - Tags assosiated with the server groups. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +--- +- name: Ensure server group exists + cloudscale_ch.cloud.server_group: + name: my-name + type: anti-affinity + api_token: xxxxxx + +- name: Ensure server group in a specific zone + cloudscale_ch.cloud.server_group: + name: my-rma-group + type: anti-affinity + zone: lpg1 + api_token: xxxxxx + +- name: Ensure a server group is absent + cloudscale_ch.cloud.server_group: + name: my-name + state: absent + api_token: xxxxxx +''' + +RETURN = ''' +--- +href: + description: API URL to get details about this server group + returned: if available + type: str + sample: https://api.cloudscale.ch/v1/server-group/cfde831a-4e87-4a75-960f-89b0148aa2cc +uuid: + description: The unique identifier for this server + returned: always + type: str + sample: cfde831a-4e87-4a75-960f-89b0148aa2cc +name: + description: The display name of the server group + returned: always + type: str + sample: load balancers +type: + description: The type the server group + returned: if available + type: str + sample: anti-affinity +zone: + description: The zone of the server group + returned: success + type: dict + sample: { 'slug': 'rma1' } +servers: + description: A list of servers that are part of the server group. + returned: if available + type: list + sample: [] +state: + description: State of the server group. + returned: always + type: str + sample: present +tags: + description: Tags assosiated with the server group. + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + name=dict(type='str'), + uuid=dict(type='str'), + type=dict(type='str', default='anti-affinity'), + zone=dict(type='str'), + tags=dict(type='dict'), + state=dict(default='present', choices=['absent', 'present']), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('name', 'uuid'),), + required_if=(('state', 'present', ('name',),),), + supports_check_mode=True, + ) + + cloudscale_server_group = AnsibleCloudscaleBase( + module, + resource_name='server-groups', + resource_create_param_keys=[ + 'name', + 'type', + 'zone', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'tags', + ], + ) + cloudscale_server_group.query_constraint_keys = [ + 'zone', + ] + + if module.params['state'] == 'absent': + result = cloudscale_server_group.absent() + else: + result = cloudscale_server_group.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/subnet.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/subnet.py new file mode 100644 index 000000000..b5e50306b --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/subnet.py @@ -0,0 +1,322 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, René Moser <rene.moser@cloudscale.ch> +# 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 + + +DOCUMENTATION = ''' +--- +module: subnet +short_description: Manages subnets on the cloudscale.ch IaaS service +description: + - Create, update and remove subnets. +author: + - René Moser (@resmo) +version_added: "1.3.0" +options: + uuid: + description: + - UUID of the subnet. + type: str + cidr: + description: + - The cidr of the subnet. + - Required if I(state=present). + type: str + network: + description: + - The name of the network the subnet is related to. + - Required if I(state=present). + type: dict + suboptions: + uuid: + description: + - The uuid of the network. + type: str + name: + description: + - The uuid of the network. + type: str + zone: + description: + - The zone the network allocated in. + type: str + gateway_address: + description: + - The gateway address of the subnet. If not set, no gateway is used. + - Cannot be within the DHCP range, which is the lowest .101-.254 in the subnet. + type: str + dns_servers: + description: + - A list of DNS resolver IP addresses, that act as DNS servers. + - If not set, the cloudscale.ch default resolvers are used. + type: list + elements: str + reset: + description: + - Resets I(gateway_address) and I(dns_servers) to default values by the API. + - "Note: Idempotency is not given." + type: bool + default: false + state: + description: + - State of the subnet. + choices: [ present, absent ] + default: present + type: str + tags: + description: + - Tags associated with the subnet. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +--- +- name: Ensure subnet exists + cloudscale_ch.cloud.subnet: + cidr: 172.16.0.0/24 + network: + uuid: 2db69ba3-1864-4608-853a-0771b6885a3a + api_token: xxxxxx + +- name: Ensure subnet exists + cloudscale_ch.cloud.subnet: + cidr: 192.168.1.0/24 + gateway_address: 192.168.1.1 + dns_servers: + - 192.168.1.10 + - 192.168.1.11 + network: + name: private + zone: lpg1 + api_token: xxxxxx + +- name: Ensure a subnet is absent + cloudscale_ch.cloud.subnet: + cidr: 172.16.0.0/24 + network: + name: private + zone: lpg1 + state: absent + api_token: xxxxxx +''' + +RETURN = ''' +--- +href: + description: API URL to get details about the subnet. + returned: success + type: str + sample: https://api.cloudscale.ch/v1/subnets/33333333-1864-4608-853a-0771b6885a3 +uuid: + description: The unique identifier for the subnet. + returned: success + type: str + sample: 33333333-1864-4608-853a-0771b6885a3 +cidr: + description: The CIDR of the subnet. + returned: success + type: str + sample: 172.16.0.0/24 +network: + description: The network object of the subnet. + returned: success + type: complex + contains: + href: + description: API URL to get details about the network. + returned: success + type: str + sample: https://api.cloudscale.ch/v1/networks/33333333-1864-4608-853a-0771b6885a3 + uuid: + description: The unique identifier for the network. + returned: success + type: str + sample: 33333333-1864-4608-853a-0771b6885a3 + name: + description: The name of the network. + returned: success + type: str + sample: my network + zone: + description: The zone the network is allocated in. + returned: success + type: dict + sample: { 'slug': 'rma1' } + version_added: 1.4.0 +gateway_address: + description: The gateway address of the subnet. + returned: success + type: str + sample: "192.168.42.1" +dns_servers: + description: List of DNS resolver IP addresses. + returned: success + type: list + sample: ["9.9.9.9", "149.112.112.112"] +state: + description: State of the subnet. + returned: success + type: str + sample: present +tags: + description: Tags associated with the subnet. + returned: success + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + + +class AnsibleCloudscaleSubnet(AnsibleCloudscaleBase): + + def __init__(self, module): + super(AnsibleCloudscaleSubnet, self).__init__( + module=module, + resource_name='subnets', + resource_key_name='cidr', + resource_create_param_keys=[ + 'cidr', + 'gateway_address', + 'dns_servers', + 'tags', + ], + resource_update_param_keys=[ + 'gateway_address', + 'dns_servers', + 'tags', + ], + ) + self._network = None + + def query_network(self, uuid=None): + if self._network is not None: + return self._network + + net_param = self._module.params['network'] + net_uuid = uuid or net_param['uuid'] + + if net_uuid is not None: + network = self._get('networks/%s' % net_uuid) + if not network: + self._module.fail_json(msg="Network with 'uuid' not found: %s" % net_uuid) + + elif net_param['name'] is not None: + networks_found = [] + networks = self._get('networks') + for network in networks or []: + # Skip networks in other zones + if net_param['zone'] is not None and network['zone']['slug'] != net_param['zone']: + continue + + if network.get('name') == net_param['name']: + networks_found.append(network) + + if not networks_found: + msg = "Network with 'name' not found: %s" % net_param['name'] + self._module.fail_json(msg=msg) + + elif len(networks_found) == 1: + network = networks_found[0] + + # We might have found more than one network with identical name + else: + msg = ("Multiple networks with 'name' not found: %s." + "Add the 'zone' to distinguish or use 'uuid' argument to specify the network." % net_param['name']) + self._module.fail_json(msg=msg) + + else: + self._module.fail_json(msg="Either Network UUID or name is required.") + + # For consistency, take a minimal network stub, but also include zone + self._network = dict() + for k, v in network.items(): + if k in ['name', 'uuid', 'href', 'zone']: + self._network[k] = v + + return self._network + + def create(self, resource): + resource['network'] = self.query_network() + + data = { + 'network': resource['network']['uuid'], + } + return super(AnsibleCloudscaleSubnet, self).create(resource, data) + + def update(self, resource): + # Resets to default values by the API + if self._module.params.get('reset'): + for key in ('dns_servers', 'gateway_address',): + # No need to reset if user set the param anyway. + if self._module.params.get(key) is None: + self._result['changed'] = True + patch_data = { + key: None + } + if not self._module.check_mode: + href = resource.get('href') + if not href: + self._module.fail_json(msg='Unable to update %s, no href found.' % key) + self._patch(href, patch_data, filter_none=False) + + if not self._module.check_mode: + resource = self.query() + + return super(AnsibleCloudscaleSubnet, self).update(resource) + + def get_result(self, resource): + if resource and 'network' in resource: + resource['network'] = self.query_network(uuid=resource['network']['uuid']) + return super(AnsibleCloudscaleSubnet, self).get_result(resource) + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + uuid=dict(type='str'), + cidr=dict(type='str'), + network=dict( + type='dict', + options=dict( + uuid=dict(type='str'), + name=dict(type='str'), + zone=dict(type='str'), + ), + ), + gateway_address=dict(type='str'), + dns_servers=dict(type='list', elements='str', default=None), + tags=dict(type='dict'), + reset=dict(type='bool', default=False), + state=dict(default='present', choices=['absent', 'present']), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('cidr', 'uuid',),), + required_together=(('cidr', 'network',),), + required_if=(('state', 'present', ('cidr', 'network',),),), + supports_check_mode=True, + ) + + cloudscale_subnet = AnsibleCloudscaleSubnet(module) + + if module.params['state'] == 'absent': + result = cloudscale_subnet.absent() + else: + result = cloudscale_subnet.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cloudscale_ch/cloud/plugins/modules/volume.py b/ansible_collections/cloudscale_ch/cloud/plugins/modules/volume.py new file mode 100644 index 000000000..ecc6cfcc6 --- /dev/null +++ b/ansible_collections/cloudscale_ch/cloud/plugins/modules/volume.py @@ -0,0 +1,265 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2018, Gaudenz Steinlin <gaudenz.steinlin@cloudscale.ch> +# Copyright (c) 2019, René Moser <mail@renemoser.net> +# 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 + + +DOCUMENTATION = ''' +--- +module: volume +short_description: Manages volumes on the cloudscale.ch IaaS service. +description: + - Create, attach/detach, update and delete volumes on the cloudscale.ch IaaS service. +notes: + - To create a new volume at least the I(name) and I(size_gb) options + are required. + - A volume can be created and attached to a server in the same task. +author: + - Gaudenz Steinlin (@gaudenz) + - René Moser (@resmo) + - Denis Krienbühl (@href) +version_added: "1.0.0" +options: + state: + description: + - State of the volume. + default: present + choices: [ present, absent ] + type: str + name: + description: + - Name of the volume. Either name or UUID must be present to change an + existing volume. + type: str + uuid: + description: + - UUID of the volume. Either name or UUID must be present to change an + existing volume. + type: str + size_gb: + description: + - Size of the volume in GB. + type: int + type: + description: + - Type of the volume. Cannot be changed after creating the volume. + Defaults to C(ssd) on volume creation. + choices: [ ssd, bulk ] + type: str + zone: + description: + - Zone in which the volume resides (e.g. C(lpg1) or C(rma1)). Cannot be + changed after creating the volume. Defaults to the project default zone. + type: str + servers: + description: + - UUIDs of the servers this volume is attached to. Set this to C([]) to + detach the volume. Currently a volume can only be attached to a + single server. + - The aliases C(server_uuids) and C(server_uuid) are deprecated and will + be removed in version 3.0.0 of this collection. + aliases: [ server_uuids, server_uuid ] + type: list + elements: str + tags: + description: + - Tags associated with the volume. Set this to C({}) to clear any tags. + type: dict +extends_documentation_fragment: cloudscale_ch.cloud.api_parameters +''' + +EXAMPLES = ''' +# Create a new SSD volume +- name: Create an SSD volume + cloudscale_ch.cloud.volume: + name: my_ssd_volume + zone: 'lpg1' + size_gb: 50 + api_token: xxxxxx + register: my_ssd_volume + +# Attach an existing volume to a server +- name: Attach volume to server + cloudscale_ch.cloud.volume: + uuid: "{{ my_ssd_volume.uuid }}" + servers: + - ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Create and attach a volume to a server +- name: Create and attach volume to server + cloudscale_ch.cloud.volume: + name: my_ssd_volume + zone: 'lpg1' + size_gb: 50 + servers: + - ea3b39a3-77a8-4d0b-881d-0bb00a1e7f48 + api_token: xxxxxx + +# Detach volume from server +- name: Detach volume from server + cloudscale_ch.cloud.volume: + uuid: "{{ my_ssd_volume.uuid }}" + servers: [] + api_token: xxxxxx + +# Delete a volume +- name: Delete volume + cloudscale_ch.cloud.volume: + name: my_ssd_volume + state: absent + api_token: xxxxxx +''' + +RETURN = ''' +href: + description: The API URL to get details about this volume. + returned: state == present + type: str + sample: https://api.cloudscale.ch/v1/volumes/2db69ba3-1864-4608-853a-0771b6885a3a +uuid: + description: The unique identifier for this volume. + returned: state == present + type: str + sample: 2db69ba3-1864-4608-853a-0771b6885a3a +name: + description: The display name of the volume. + returned: state == present + type: str + sample: my_ssd_volume +size_gb: + description: The size of the volume in GB. + returned: state == present + type: str + sample: 50 +type: + description: The type of the volume. + returned: state == present + type: str + sample: bulk +zone: + description: The zone of the volume. + returned: state == present + type: dict + sample: {'slug': 'lpg1'} +server_uuids: + description: The UUIDs of the servers this volume is attached to. This return + value is deprecated and will disappear in the future when the field is + removed from the API. + returned: state == present + type: list + sample: ['47cec963-fcd2-482f-bdb6-24461b2d47b1'] +servers: + description: The list of servers this volume is attached to. + returned: state == present + type: list + sample: [ + { + "href": "https://api.cloudscale.ch/v1/servers/47cec963-fcd2-482f-bdb6-24461b2d47b1", + "name": "my_server", + "uuid": "47cec963-fcd2-482f-bdb6-24461b2d47b1" + } + ] +state: + description: The current status of the volume. + returned: success + type: str + sample: present +tags: + description: Tags associated with the volume. + returned: state == present + type: dict + sample: { 'project': 'my project' } +''' + +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.api import ( + AnsibleCloudscaleBase, + cloudscale_argument_spec, +) + + +class AnsibleCloudscaleVolume(AnsibleCloudscaleBase): + + def create(self, resource): + # Fail when missing params for creation + self._module.fail_on_missing_params(['name', 'size_gb']) + return super(AnsibleCloudscaleVolume, self).create(resource) + + def find_difference(self, key, resource, param): + is_different = False + + if key != 'servers': + return super(AnsibleCloudscaleVolume, self).find_difference(key, resource, param) + + server_has = resource[key] + server_wanted = param + if len(server_wanted) != len(server_has): + is_different = True + else: + for has in server_has: + if has["uuid"] not in server_wanted: + is_different = True + + return is_different + + +def main(): + argument_spec = cloudscale_argument_spec() + argument_spec.update(dict( + state=dict(type='str', default='present', choices=('present', 'absent')), + name=dict(type='str'), + uuid=dict(type='str'), + zone=dict(type='str'), + size_gb=dict(type='int'), + type=dict(type='str', choices=('ssd', 'bulk')), + servers=dict(type='list', elements='str', aliases=['server_uuids', 'server_uuid']), + tags=dict(type='dict'), + )) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(('name', 'uuid'),), + supports_check_mode=True, + ) + + # TODO remove in version 3.0.0 + if module.params.get('server_uuid') or module.params.get('server_uuids'): + module.deprecate('The aliases "server_uuid" and "server_uuids" have ' + 'been deprecated and will be removed, use "servers" ' + 'instead.', + version='3.0.0', collection_name='cloudscale_ch.cloud') + + cloudscale_volume = AnsibleCloudscaleVolume( + module, + resource_name='volumes', + resource_create_param_keys=[ + 'name', + 'type', + 'zone', + 'size_gb', + 'servers', + 'tags', + ], + resource_update_param_keys=[ + 'name', + 'size_gb', + 'servers', + 'tags', + ], + ) + + if module.params['state'] == 'absent': + result = cloudscale_volume.absent() + else: + result = cloudscale_volume.present() + module.exit_json(**result) + + +if __name__ == '__main__': + main() |