Merge "Add volume_retype module"

This commit is contained in:
Zuul
2026-05-21 14:00:57 +00:00
committed by Gerrit Code Review
6 changed files with 516 additions and 0 deletions

View File

@@ -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

View File

@@ -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 }}"

View File

@@ -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 }

View File

@@ -102,3 +102,4 @@ action_groups:
- volume_snapshot_info
- volume_type_access
- volume_image_metadata
- volume_retype

View File

@@ -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": "<type>", "migration_policy": "<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()

View File

@@ -0,0 +1,5 @@
---
features:
- |
Added a new ``openstack.cloud.volume_retype`` module to retype
Cinder volumes via the ``os-retype`` API.