From 147ad6c4526f09771bc4425dc63bbb9c853476c9 Mon Sep 17 00:00:00 2001 From: Denys Mishchenko Date: Fri, 4 Aug 2023 15:02:50 +0200 Subject: [PATCH] Add volume_type related plugins/modules Added 2 new modules to manipulate volume types in OpenStack * volume_type is used to create, delete and modify volume type * volume_type_info is used to show volume_type details, including encryption information ci tests extended with additional role to test basic module behaviour It is currently impossible to update is_public volume type attribute as it is being changed to "os-volume-type-access:is_public" which is not expected by api. Which expects just "is_public" https://docs.openstack.org/api-ref/block-storage/v3/?expanded=update-a-volume-type-detail#update-a-volume-type Which results in "'os-volume-type-access:is_public' was unexpected" reply. I guess the change is required by openstacksdk or on the API side Change-Id: Idc26a5240b5f3314c8384c7326d8a82dcc8c6171 --- ci/roles/volume_type/defaults/main.yml | 12 ++ ci/roles/volume_type/tasks/main.yml | 82 +++++++++ ci/run-collection.yml | 1 + plugins/modules/volume_type.py | 241 +++++++++++++++++++++++++ plugins/modules/volume_type_info.py | 175 ++++++++++++++++++ 5 files changed, 511 insertions(+) create mode 100644 ci/roles/volume_type/defaults/main.yml create mode 100644 ci/roles/volume_type/tasks/main.yml create mode 100644 plugins/modules/volume_type.py create mode 100644 plugins/modules/volume_type_info.py diff --git a/ci/roles/volume_type/defaults/main.yml b/ci/roles/volume_type/defaults/main.yml new file mode 100644 index 00000000..8d009258 --- /dev/null +++ b/ci/roles/volume_type/defaults/main.yml @@ -0,0 +1,12 @@ +--- +volume_backend_name: LVM_iSCSI +volume_type_name: test_type +volume_type_description: Test volume type +volume_type_alt_name: changed_type +volume_type_alt_description: Changed test volume type + +enc_provider_name: nova.volume.encryptors.luks.LuksEncryptor +enc_cipher: aes-xts-plain64 +enc_control_location: front-end +enc_control_alt_location: back-end +enc_key_size: 256 diff --git a/ci/roles/volume_type/tasks/main.yml b/ci/roles/volume_type/tasks/main.yml new file mode 100644 index 00000000..06e606ba --- /dev/null +++ b/ci/roles/volume_type/tasks/main.yml @@ -0,0 +1,82 @@ +--- +- name: Create volume type + openstack.cloud.volume_type: + name: "{{ volume_type_name }}" + cloud: "{{ cloud }}" + state: present + extra_specs: + volume_backend_name: "{{ volume_backend_name }}" + description: "{{ volume_type_description }}" + is_public: true + register: the_result +- name: Check created volume type + vars: + the_volume: "{{ the_result.volume_type }}" + ansible.builtin.assert: + that: + - "'id' in the_result.volume_type" + - the_volume.description == volume_type_description + - the_volume.is_public == True + - the_volume.name == volume_type_name + - the_volume.extra_specs['volume_backend_name'] == volume_backend_name + success_msg: >- + Created volume: {{ the_result.volume_type.id }}, + Name: {{ the_result.volume_type.name }}, + Description: {{ the_result.volume_type.description }} + +- name: Test, check idempotency + openstack.cloud.volume_type: + name: "{{ volume_type_name }}" + cloud: "{{ cloud }}" + state: present + extra_specs: + volume_backend_name: "{{ volume_backend_name }}" + description: "{{ volume_type_description }}" + is_public: true + register: the_result +- name: Check result.changed is false + ansible.builtin.assert: + that: + - the_result.changed == false + success_msg: "Request with the same details lead to no changes" + +- name: Add extra spec + openstack.cloud.volume_type: + cloud: "{{ cloud }}" + name: "{{ volume_type_name }}" + state: present + extra_specs: + volume_backend_name: "{{ volume_backend_name }}" + some_spec: fake_spec + description: "{{ volume_type_alt_description }}" + is_public: true + register: the_result +- name: Check volume type extra spec + ansible.builtin.assert: + that: + - "'some_spec' in the_result.volume_type.extra_specs" + - the_result.volume_type.extra_specs["some_spec"] == "fake_spec" + success_msg: >- + New extra specs: {{ the_result.volume_type.extra_specs }} + +# is_public update attempt using openstacksdk result in unexpected attribute +# error... TODO: Find solution +# +# - name: Make volume type private +# openstack.cloud.volume_type: +# cloud: "{{ cloud }}" +# name: "{{ volume_type_alt_name }}" +# state: present +# extra_specs: +# volume_backend_name: "{{ volume_backend_name }}" +# # some_other_spec: test +# description: Changed 3rd time test volume type +# is_public: true +# register: the_result + +- name: Delete volume type + openstack.cloud.volume_type: + cloud: "{{ cloud }}" + name: "{{ volume_type_name }}" + state: absent + register: the_result diff --git a/ci/run-collection.yml b/ci/run-collection.yml index 083737fc..1771106b 100644 --- a/ci/run-collection.yml +++ b/ci/run-collection.yml @@ -53,6 +53,7 @@ - { role: subnet, tags: subnet } - { role: subnet_pool, tags: subnet_pool } - { role: volume, tags: volume } + - { role: volume_type, tags: volume_type } - { role: volume_backup, tags: volume_backup } - { role: volume_snapshot, tags: volume_snapshot } - { role: volume_type_access, tags: volume_type_access } diff --git a/plugins/modules/volume_type.py b/plugins/modules/volume_type.py new file mode 100644 index 00000000..4f9f3be7 --- /dev/null +++ b/plugins/modules/volume_type.py @@ -0,0 +1,241 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Cleura AB +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: volume_type +short_description: Manage OpenStack volume type +author: OpenStack Ansible SIG +description: + - Add, remove or update volume types in OpenStack. +options: + name: + description: + - Volume type name or id. + required: true + type: str + description: + description: + - Description of the volume type. + type: str + extra_specs: + description: + - List of volume type properties + type: dict + is_public: + description: + - Make volume type accessible to the public. + - Can be set only during creation + type: bool + state: + description: + - Indicate desired state of the resource. + - When I(state) is C(present), then I(is_public) is required. + choices: ['present', 'absent'] + default: present + type: str +extends_documentation_fragment: + - openstack.cloud.openstack +''' + +EXAMPLES = r''' + - name: Delete volume type by name + openstack.cloud.volume_type: + name: test_type + state: absent + + - name: Delete volume type by id + openstack.cloud.volume_type: + name: fbadfa6b-5f17-4c26-948e-73b94de57b42 + state: absent + + - name: Create volume type + openstack.cloud.volume_type: + name: unencrypted_volume_type + state: present + extra_specs: + volume_backend_name: LVM_iSCSI + description: Unencrypted volume type + is_public: True +''' + +RETURN = ''' +volume_type: + description: Dictionary describing volume type + returned: On success when I(state) is 'present' + type: dict + contains: + name: + description: volume type name + returned: success + type: str + sample: test_type + extra_specs: + description: volume type extra parameters + returned: success + type: dict + sample: null + is_public: + description: whether the volume type is public + returned: success + type: bool + sample: True + description: + description: volume type description + returned: success + type: str + sample: Unencrypted volume type +''' + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule + + +class VolumeTypeModule(OpenStackModule): + argument_spec = dict( + name=dict(type='str', required=True), + description=dict(type='str', required=False), + extra_specs=dict(type='dict', required=False), + is_public=dict(type='bool'), + state=dict( + type='str', default='present', choices=['absent', 'present']), + ) + module_kwargs = dict( + required_if=[('state', 'present', ['is_public'])], + supports_check_mode=True, + ) + + @staticmethod + def _extract_result(details): + if details is not None: + return details.to_dict(computed=False) + return {} + + def run(self): + state = self.params['state'] + name_or_id = self.params['name'] + volume_type = self.conn.block_storage.find_type(name_or_id) + + if self.ansible.check_mode: + self.exit_json( + changed=self._will_change(state, volume_type)) + + if state == 'present' and not volume_type: + # Create type + create_result = self._create() + volume_type = self._extract_result(create_result) + self.exit_json(changed=True, volume_type=volume_type) + + elif state == 'present' and volume_type: + # Update type + update = self._build_update(volume_type) + update_result = self._update(volume_type, update) + volume_type = self._extract_result(update_result) + self.exit_json(changed=bool(update), volume_type=volume_type) + + elif state == 'absent' and volume_type: + # Delete type + self._delete(volume_type) + self.exit_json(changed=True) + + def _build_update(self, volume_type): + return { + **self._build_update_extra_specs(volume_type), + **self._build_update_volume_type(volume_type)} + + def _build_update_extra_specs(self, volume_type): + update = {} + + old_extra_specs = volume_type['extra_specs'] + new_extra_specs = self.params['extra_specs'] or {} + + delete_extra_specs_keys = \ + set(old_extra_specs.keys()) - set(new_extra_specs.keys()) + + if delete_extra_specs_keys: + update['delete_extra_specs_keys'] = delete_extra_specs_keys + + stringified = {k: str(v) for k, v in new_extra_specs.items()} + + if old_extra_specs != stringified: + update['create_extra_specs'] = new_extra_specs + + return update + + def _build_update_volume_type(self, volume_type): + update = {} + allowed_attributes = [ + 'is_public', 'description', 'name'] + type_attributes = { + k: self.params[k] + for k in allowed_attributes + if k in self.params and self.params.get(k) is not None + and self.params.get(k) != volume_type.get(k)} + + if type_attributes: + update['type_attributes'] = type_attributes + + return update + + def _create(self): + kwargs = {k: self.params[k] + for k in ['name', 'is_public', 'description', 'extra_specs'] + if self.params.get(k) is not None} + volume_type = self.conn.block_storage.create_type(**kwargs) + return volume_type + + def _delete(self, volume_type): + self.conn.block_storage.delete_type(volume_type.id) + + def _update(self, volume_type, update): + if not update: + return volume_type + volume_type = self._update_volume_type(volume_type, update) + volume_type = self._update_extra_specs(volume_type, update) + return volume_type + + def _update_extra_specs(self, volume_type, update): + delete_extra_specs_keys = update.get('delete_extra_specs_keys') + if delete_extra_specs_keys: + self.conn.block_storage.delete_type_extra_specs( + volume_type, delete_extra_specs_keys) + # refresh volume_type information + volume_type = self.conn.block_storage.find_type(volume_type.id) + + create_extra_specs = update.get('create_extra_specs') + if create_extra_specs: + self.conn.block_storage.update_type_extra_specs( + volume_type, **create_extra_specs) + # refresh volume_type information + volume_type = self.conn.block_storage.find_type(volume_type.id) + + return volume_type + + def _update_volume_type(self, volume_type, update): + type_attributes = update.get('type_attributes') + if type_attributes: + updated_type = self.conn.block_storage.update_type( + volume_type, **type_attributes) + return updated_type + return volume_type + + def _will_change(self, state, volume_type): + if state == 'present' and not volume_type: + return True + if state == 'present' and volume_type: + return bool(self._build_update(volume_type)) + if state == 'absent' and volume_type: + return True + return False + + +def main(): + module = VolumeTypeModule() + module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/volume_type_info.py b/plugins/modules/volume_type_info.py new file mode 100644 index 00000000..55654ce2 --- /dev/null +++ b/plugins/modules/volume_type_info.py @@ -0,0 +1,175 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Cleura AB +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: volume_type_info +short_description: Get OpenStack volume type details +author: OpenStack Ansible SIG +description: + - Get volume type details in OpenStack. + - Get volume type encryption details in OpenStack +options: + name: + description: + - Volume type name or id. + required: true + type: str +extends_documentation_fragment: + - openstack.cloud.openstack +''' + +EXAMPLES = r''' + - name: Get volume type details + openstack.cloud.volume_type_info: + name: test_type + + - name: Get volume type details by id + openstack.cloud.volume_type_info: + name: fbadfa6b-5f17-4c26-948e-73b94de57b42 +''' + +RETURN = ''' +access_project_ids: + description: + - List of project IDs allowed to access volume type + - Public volume types returns 'null' value as it is not applicable + returned: On success when I(state) is 'present' + type: list + elements: str +volume_type: + description: Dictionary describing volume type + returned: On success when I(state) is 'present' + type: dict + contains: + id: + description: volume_type uuid + returned: success + type: str + sample: b75d8c5c-a6d8-4a5d-8c86-ef4f1298525d + name: + description: volume type name + returned: success + type: str + sample: test_type + extra_specs: + description: volume type extra parameters + returned: success + type: dict + sample: null + is_public: + description: whether the volume type is public + returned: success + type: bool + sample: True + description: + description: volume type description + returned: success + type: str + sample: Unencrypted volume type +encryption: + description: Dictionary describing volume type encryption + returned: On success when I(state) is 'present' + type: dict + contains: + cipher: + description: encryption cipher + returned: success + type: str + sample: aes-xts-plain64 + control_location: + description: encryption location + returned: success + type: str + sample: front-end + created_at: + description: Resource creation date and time + returned: success + type: str + sample: "2023-08-04T10:23:03.000000" + deleted: + description: Boolean if the resource was deleted + returned: success + type: str + sample: false + deleted_at: + description: Resource delete date and time + returned: success + type: str + sample: null + encryption_id: + description: UUID of the volume type encryption + returned: success + type: str + sample: b75d8c5c-a6d8-4a5d-8c86-ef4f1298525d + id: + description: Alias to encryption_id + returned: success + type: str + sample: b75d8c5c-a6d8-4a5d-8c86-ef4f1298525d + key_size: + description: Size of the key + returned: success + type: str + sample: 256 + provider: + description: Encryption provider + returned: success + type: str + sample: "nova.volume.encryptors.luks.LuksEncryptor" + updated_at: + description: Resource last update date and time + returned: success + type: str + sample: null +''' + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule + + +class VolumeTypeModule(OpenStackModule): + argument_spec = dict( + name=dict(type='str', required=True) + ) + module_kwargs = dict( + supports_check_mode=True, + ) + + @staticmethod + def _extract_result(details): + if details is not None: + return details.to_dict(computed=False) + return {} + + def run(self): + name_or_id = self.params['name'] + volume_type = self.conn.block_storage.find_type(name_or_id) + + type_encryption = self.conn.block_storage.get_type_encryption( + volume_type.id) + + if volume_type.is_public: + type_access = None + else: + type_access = [ + proj['project_id'] + for proj in self.conn.block_storage.get_type_access( + volume_type.id)] + + self.exit_json( + changed=False, + volume_type=self._extract_result(volume_type), + encryption=self._extract_result(type_encryption), + access_project_ids=type_access) + + +def main(): + module = VolumeTypeModule() + module() + + +if __name__ == '__main__': + main()