diff --git a/ci/roles/volume_retype/defaults/main.yml b/ci/roles/volume_retype/defaults/main.yml new file mode 100644 index 00000000..ba5e77af --- /dev/null +++ b/ci/roles/volume_retype/defaults/main.yml @@ -0,0 +1,5 @@ +--- +volume_retype_cloud: "{{ cloud | default(omit) }}" +vtype_src_name: ansible_test_vtype_src +vtype_dst_name: ansible_test_vtype_dst +volume_name: ansible_test_retype_vol diff --git a/ci/roles/volume_retype/tasks/main.yml b/ci/roles/volume_retype/tasks/main.yml new file mode 100644 index 00000000..43860b3f --- /dev/null +++ b/ci/roles/volume_retype/tasks/main.yml @@ -0,0 +1,183 @@ +--- +# ci/roles/volume_retype/tasks/main.yml +# Integration tests for openstack.cloud.volume_retype +# +# Prerequisites (set up in surrounding playbook or defaults): +# - cloud: devstack-admin +# - A DevStack instance with at least two volume types +# +# The tests follow the collection's pattern: +# 1. Create prerequisites (volume types, volume) +# 2. Run module - assert changed +# 3. Re-run module - assert NOT changed (idempotency) +# 4. Validate returned data +# 5. Clean up + +- name: Create source volume type + openstack.cloud.volume_type: + cloud: "{{ volume_retype_cloud }}" + state: present + name: "{{ vtype_src_name }}" + is_public: true + register: vtype_src + +- name: Create destination volume type + openstack.cloud.volume_type: + cloud: "{{ volume_retype_cloud }}" + state: present + name: "{{ vtype_dst_name }}" + is_public: true + register: vtype_dst + +- name: Create a test volume using the source type + openstack.cloud.volume: + cloud: "{{ volume_retype_cloud }}" + state: present + name: "{{ volume_name }}" + size: 1 + volume_type: "{{ vtype_src_name }}" + wait: true + register: test_volume + +- name: Assert volume was created with the source type + ansible.builtin.assert: + that: + - test_volume.volume.volume_type == vtype_src_name + - test_volume.volume.status == 'available' + +# ------------------------------------------------------------------ +# Test 1: Basic retype (migration_policy=never, same-backend assumed) +# ------------------------------------------------------------------ +- name: Retype volume to destination type (migration_policy=never) + openstack.cloud.volume_retype: + cloud: "{{ volume_retype_cloud }}" + volume: "{{ volume_name }}" + new_type: "{{ vtype_dst_name }}" + migration_policy: never + wait: true + timeout: 120 + register: retype_result + +- name: Assert retype was applied + ansible.builtin.assert: + that: + - retype_result is changed + - retype_result.volume.volume_type == vtype_dst_name + - retype_result.volume.status == 'available' + +# ------------------------------------------------------------------ +# Test 2: Idempotency – re-running with same target type must be no-op +# ------------------------------------------------------------------ +- name: Re-run retype with same target type (idempotency check) + openstack.cloud.volume_retype: + cloud: "{{ volume_retype_cloud }}" + volume: "{{ volume_name }}" + new_type: "{{ vtype_dst_name }}" + migration_policy: never + wait: true + register: retype_idempotent + +- name: Assert no change on second run + ansible.builtin.assert: + that: + - retype_idempotent is not changed + +# ------------------------------------------------------------------ +# Test 3: Check mode – must report changed without touching the API +# ------------------------------------------------------------------ +- name: Check mode – retype back to source type (should report changed, no action) + openstack.cloud.volume_retype: + cloud: "{{ volume_retype_cloud }}" + volume: "{{ volume_name }}" + new_type: "{{ vtype_src_name }}" + migration_policy: never + check_mode: true + register: retype_check_mode + +- name: Assert check mode reports changed but volume type is still the destination + ansible.builtin.assert: + that: + - retype_check_mode is changed + +- name: Confirm actual volume type did NOT change (check mode was dry-run) + openstack.cloud.volume_info: + cloud: "{{ volume_retype_cloud }}" + name: "{{ volume_name }}" + register: volume_info_after_check + +- name: Assert volume type unchanged after check mode run + ansible.builtin.assert: + that: + - volume_info_after_check.volumes[0].volume_type == vtype_dst_name + +# ------------------------------------------------------------------ +# Test 4: Retype by volume ID (not name) +# ------------------------------------------------------------------ +- name: Retype volume using its UUID + openstack.cloud.volume_retype: + cloud: "{{ volume_retype_cloud }}" + volume: "{{ test_volume.volume.id }}" + new_type: "{{ vtype_src_name }}" + migration_policy: never + wait: true + register: retype_by_id + +- name: Assert retype by ID succeeded + ansible.builtin.assert: + that: + - retype_by_id is changed + - retype_by_id.volume.volume_type == vtype_src_name + +# ------------------------------------------------------------------ +# Test 5: Error handling – non-existent volume +# ------------------------------------------------------------------ +- name: Attempt retype of non-existent volume (expect failure) + openstack.cloud.volume_retype: + cloud: "{{ volume_retype_cloud }}" + volume: this-volume-does-not-exist + new_type: "{{ vtype_dst_name }}" + register: retype_missing + ignore_errors: true + +- name: Assert that missing volume causes failure + ansible.builtin.assert: + that: + - retype_missing is failed + +# ------------------------------------------------------------------ +# Test 6: Error handling – non-existent volume type +# ------------------------------------------------------------------ +- name: Attempt retype to a non-existent volume type (expect failure) + openstack.cloud.volume_retype: + cloud: "{{ volume_retype_cloud }}" + volume: "{{ volume_name }}" + new_type: this-type-does-not-exist + register: retype_missing_type + ignore_errors: true + +- name: Assert that missing volume type causes failure + ansible.builtin.assert: + that: + - retype_missing_type is failed + +# ------------------------------------------------------------------ +# Clean up +# ------------------------------------------------------------------ +- name: Delete test volume + openstack.cloud.volume: + cloud: "{{ volume_retype_cloud }}" + state: absent + name: "{{ volume_name }}" + wait: true + +- name: Delete source volume type + openstack.cloud.volume_type: + cloud: "{{ volume_retype_cloud }}" + state: absent + name: "{{ vtype_src_name }}" + +- name: Delete destination volume type + openstack.cloud.volume_type: + cloud: "{{ volume_retype_cloud }}" + state: absent + name: "{{ vtype_dst_name }}" diff --git a/ci/run-collection.yml b/ci/run-collection.yml index e65e5883..46ee2dd9 100644 --- a/ci/run-collection.yml +++ b/ci/run-collection.yml @@ -67,3 +67,4 @@ - { role: volume_snapshot, tags: volume_snapshot } - { role: volume_type_access, tags: volume_type_access } - { role: volume_image_metadata, tags: volume_image_metadata } + - { role: volume_retype, tags: volume_retype } diff --git a/meta/runtime.yml b/meta/runtime.yml index e5d2d473..fbd1d5c2 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -102,3 +102,4 @@ action_groups: - volume_snapshot_info - volume_type_access - volume_image_metadata + - volume_retype diff --git a/plugins/modules/volume_retype.py b/plugins/modules/volume_retype.py new file mode 100644 index 00000000..f3a943ff --- /dev/null +++ b/plugins/modules/volume_retype.py @@ -0,0 +1,321 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 OpenStack Ansible Contributors +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: volume_retype +short_description: Retype (change the volume type of) a Cinder block storage volume +author: Simon Dodsley (@simondodsley) +description: + - Change the volume type of an existing OpenStack Block Storage (Cinder) volume. + - This operation may trigger a data migration between storage backends, depending + on the chosen C(migration_policy). + - The volume must be in C(available) or C(in-use) status to be retyped. + - Retyping an in-use volume from a multiattach-capable type to a + non-multiattach-capable type (or vice-versa) is not supported by Cinder. + - Retyping an encrypted volume to an unencrypted volume (or vice-versa) is not + supported by Cinder. +options: + volume: + description: + - Name or ID of the volume to retype. + type: str + required: true + aliases: ['name'] + new_type: + description: + - Name or ID of the target volume type. + type: str + required: true + migration_policy: + description: + - Migration policy to apply when changing the volume type. + - C(never) changes the volume type metadata only, without migrating data. + This only works when the source and target types share the same storage + backend. This is the Cinder default. + - C(on-demand) triggers a full data migration to the backend associated + with the new volume type. This allows moving between different backends + but may take a significant amount of time depending on volume size. + type: str + choices: ['never', 'on-demand'] + default: 'never' + wait: + description: + - Whether to wait for the retype operation to complete before returning. + - When I(wait=true) and I(migration_policy=on-demand) the module will + poll until the volume C(migration_status) is C(success) or C(error). + - When I(wait=true) and I(migration_policy=never) the module waits until + the volume returns to C(available) status. + type: bool + default: true + timeout: + description: + - How long (in seconds) to wait for the retype operation to complete. + - Ignored when I(wait=false). + type: int + default: 600 +extends_documentation_fragment: + - openstack.cloud.openstack +''' + +EXAMPLES = r''' +- name: Retype a volume using the default "never" migration policy + openstack.cloud.volume_retype: + cloud: mycloud + volume: my-volume + new_type: ssd-standard + +- name: Retype a volume with on-demand migration (moves data to the new backend) + openstack.cloud.volume_retype: + cloud: mycloud + volume: my-volume + new_type: high-iops + migration_policy: on-demand + timeout: 1800 + +- name: Retype a volume without waiting for completion + openstack.cloud.volume_retype: + cloud: mycloud + volume: my-volume + new_type: cold-storage + migration_policy: on-demand + wait: false + +- name: Retype volume identified by UUID + openstack.cloud.volume_retype: + cloud: mycloud + volume: a1b2c3d4-1234-5678-abcd-ef0123456789 + new_type: replicated-ssd + migration_policy: on-demand +''' + +RETURN = r''' +volume: + description: Dictionary describing the volume after the retype operation. + returned: On success. + type: dict + contains: + attachments: + description: Instance attachments for the volume. + type: list + elements: dict + availability_zone: + description: The availability zone of the volume. + type: str + bootable: + description: Whether the volume is bootable. + type: bool + consistencygroup_id: + description: The ID of the consistency group that the volume belongs to. + type: str + created_at: + description: Timestamp of when the volume was created. + type: str + description: + description: Human-readable description of the volume. + type: str + encrypted: + description: Whether the volume is encrypted. + type: bool + id: + description: Unique ID of the volume. + type: str + metadata: + description: Metadata associated with the volume. + type: dict + migration_status: + description: + - Status of the most recent migration operation. + - Will be C(success) after a successful on-demand retype. + type: str + name: + description: Name of the volume. + type: str + replication_status: + description: Replication status of the volume. + type: str + size: + description: Volume size in GiB. + type: int + snapshot_id: + description: Snapshot ID from which the volume was created. + type: str + source_volid: + description: ID of the source volume, if the volume was cloned from another. + type: str + status: + description: Volume status. Will be C(available) after a successful retype. + type: str + updated_at: + description: Timestamp of the most recent volume update. + type: str + volume_type: + description: The volume type of the volume after the retype. + type: str + volume_type_id: + description: The ID of the volume type after the retype. + type: str +''' + +import time + +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import ( + OpenStackModule, +) + + +class VolumeRetypeModule(OpenStackModule): + + argument_spec = dict( + volume=dict(required=True, aliases=['name']), + new_type=dict(required=True), + migration_policy=dict( + default='never', + choices=['never', 'on-demand'], + ), + wait=dict(type='bool', default=True), + timeout=dict(type='int', default=600), + ) + + module_kwargs = dict( + supports_check_mode=True, + ) + + def _get_volume(self, name_or_id): + """Retrieve the Cinder volume resource, failing if not found.""" + volume = self.conn.block_storage.find_volume( + name_or_id, ignore_missing=False + ) + return volume + + def _get_volume_type(self, name_or_id): + """Retrieve the Cinder volume type resource, failing if not found. + + Uses the v3 proxy's find_type(name_or_id, ignore_missing=False) which + accepts both names and UUIDs and raises NotFoundException on miss. + """ + return self.conn.block_storage.find_type( + name_or_id, ignore_missing=False + ) + + def _volume_needs_retype(self, volume, target_type): + """Return True when the volume's current type differs from the target.""" + # openstacksdk Volume resources expose volume_type (name) and + # volume_type_id; compare by ID when available, fall back to name. + if volume.volume_type_id and target_type.id: + return volume.volume_type_id != target_type.id + return (volume.volume_type or '').lower() != (target_type.name or '').lower() + + def _wait_for_retype(self, volume_id, migration_policy, timeout): + """ + Block until the volume completes the retype, or raise on timeout/error. + + For on-demand policy: poll migration_status until 'success' or 'error'. + For never policy: poll status until 'available' or an error state. + """ + error_statuses = {'error', 'error_deleting', 'error_restoring', + 'error_extending', 'error_managing'} + + deadline = time.time() + timeout + while time.time() < deadline: + volume = self.conn.block_storage.get_volume(volume_id) + + if migration_policy == 'on-demand': + migration_status = getattr(volume, 'migration_status', None) or '' + if migration_status == 'success': + return volume + if migration_status == 'error': + self.fail_json( + msg="Volume retype migration failed: migration_status=error", + volume=volume.to_dict(), + ) + else: # migration_policy == 'never' + status = (volume.status or '').lower() + if status == 'available': + return volume + if status in error_statuses: + self.fail_json( + msg="Volume entered error state during retype: " + "status={0}".format(volume.status), + volume=volume.to_dict(), + ) + + time.sleep(5) + + self.fail_json( + msg="Timed out waiting for volume retype to complete after " + "{0} seconds.".format(timeout) + ) + + def run(self): + volume_param = self.params['volume'] + new_type_param = self.params['new_type'] + migration_policy = self.params['migration_policy'] + wait = self.params['wait'] + timeout = self.params['timeout'] + + # ---- Resolve the volume ---- + volume = self._get_volume(volume_param) + + # ---- Resolve the target volume type ---- + target_type = self._get_volume_type(new_type_param) + + # ---- Idempotency check ---- + if not self._volume_needs_retype(volume, target_type): + self.exit_json( + changed=False, + volume=volume.to_dict(), + ) + + # ---- Validate volume status ---- + allowed_statuses = {'available', 'in-use'} + if (volume.status or '').lower() not in allowed_statuses: + self.fail_json( + msg="Cannot retype volume '{0}': status must be one of {1}, " + "but is '{2}'.".format( + volume.id, + allowed_statuses, + volume.status, + ) + ) + + # ---- Check mode: report what would change without making API calls ---- + if self.ansible.check_mode: + self.exit_json( + changed=True, + volume=volume.to_dict(), + ) + + # ---- Perform the retype ---- + # openstacksdk exposes retype via the block_storage proxy (SDK >= 1.x). + # The Cinder REST action is POST /volumes/{id}/action with body: + # {"os-retype": {"new_type": "", "migration_policy": ""}} + self.conn.block_storage.retype_volume( + volume.id, + new_type=target_type.id, + migration_policy=migration_policy, + ) + + # ---- Optionally wait ---- + if wait: + volume = self._wait_for_retype(volume.id, migration_policy, timeout) + else: + volume = self.conn.block_storage.get_volume(volume.id) + + self.exit_json( + changed=True, + volume=volume.to_dict(), + ) + + +def main(): + module = VolumeRetypeModule() + module() + + +if __name__ == '__main__': + main() diff --git a/releasenotes/notes/volume-retype-module-69df22ef9a10091d.yaml b/releasenotes/notes/volume-retype-module-69df22ef9a10091d.yaml new file mode 100644 index 00000000..55b83efd --- /dev/null +++ b/releasenotes/notes/volume-retype-module-69df22ef9a10091d.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Added a new ``openstack.cloud.volume_retype`` module to retype + Cinder volumes via the ``os-retype`` API.