|
| 1 | +#!/usr/bin/python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +# Copyright: (c) 2025, Simon Bärlocher (@sbaerlocher) <s.baerlocher@sbaerlocher.ch> |
| 5 | +# Copyright: (c) 2025, whatwedo GmbH (https://whatwedo.ch) |
| 6 | +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) |
| 7 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 8 | + |
| 9 | +""" |
| 10 | +This module implements the Ansible module 'vcenter_object_move', which moves a vCenter |
| 11 | +inventory object (e.g. a VirtualMachine, Host, Datastore, Network, or Folder) to a specified |
| 12 | +destination folder within the appropriate inventory branch. |
| 13 | +""" |
| 14 | + |
| 15 | +from typing import cast |
| 16 | + |
| 17 | +from ansible.module_utils._text import to_native |
| 18 | +from ansible.module_utils.basic import AnsibleModule |
| 19 | +from ansible_collections.community.vmware.plugins.module_utils.vmware import ( |
| 20 | + PyVmomi, |
| 21 | + find_datacenter_by_name, |
| 22 | + vmware_argument_spec, |
| 23 | + wait_for_task, |
| 24 | +) |
| 25 | +from pyVmomi import vim |
| 26 | + |
| 27 | +DOCUMENTATION = r""" |
| 28 | +--- |
| 29 | +module: vcenter_object_move |
| 30 | +short_description: Moves an inventory object to a specified destination folder in vCenter |
| 31 | +description: |
| 32 | + - Moves an inventory object (e.g. a VirtualMachine, Host, Datastore, Network or Folder) to a specified destination folder within the appropriate inventory branch. |
| 33 | + - The destination folder is specified as a slash-separated path relative to the datacenter's base folder. |
| 34 | + - Supported object types: |
| 35 | + - C(vm): Virtual Machines, vApps and Folders under the VM folder. |
| 36 | + - C(host): Hosts and Folders under the Host folder. |
| 37 | + - C(datastore): Datastores and Folders under the Datastore folder. |
| 38 | + - C(network): Networks and Folders under the Network folder. |
| 39 | + - If the object is already located in the target folder, no action is taken (idempotence). |
| 40 | +author: |
| 41 | + - Simon Bärlocher (@sbaerlocher) |
| 42 | + - whatwedo GmbH (@whatwedo) |
| 43 | +options: |
| 44 | + datacenter: |
| 45 | + description: |
| 46 | + - Name of the datacenter. |
| 47 | + required: true |
| 48 | + aliases: [ datacenter_name ] |
| 49 | + type: str |
| 50 | + object_name: |
| 51 | + description: |
| 52 | + - Name of the inventory object to move. |
| 53 | + required: true |
| 54 | + type: str |
| 55 | + object_type: |
| 56 | + description: |
| 57 | + - Inventory branch where the object resides. |
| 58 | + - Determines the base folder for both object lookup and destination folder traversal. |
| 59 | + required: false |
| 60 | + type: str |
| 61 | + default: vm |
| 62 | + choices: [ vm, host, datastore, network ] |
| 63 | + destination_folder: |
| 64 | + description: |
| 65 | + - Destination folder path relative to the base folder of the chosen object_type. |
| 66 | + - Example: C(NewFolder) or C(folder1/subfolder2) |
| 67 | + required: true |
| 68 | + type: str |
| 69 | + state: |
| 70 | + description: |
| 71 | + - Desired state. |
| 72 | + - Only C(present) is supported. |
| 73 | + required: false |
| 74 | + type: str |
| 75 | + default: present |
| 76 | + choices: [ present ] |
| 77 | +extends_documentation_fragment: |
| 78 | + - community.vmware.vmware.documentation |
| 79 | +""" |
| 80 | + |
| 81 | +EXAMPLES = r""" |
| 82 | +- name: Move a VM to a new folder |
| 83 | + vcenter_object_move: |
| 84 | + hostname: "{{ vcenter_hostname }}" |
| 85 | + username: "{{ vcenter_username }}" |
| 86 | + password: "{{ vcenter_password }}" |
| 87 | + datacenter: "DC0" |
| 88 | + object_name: "MyVM" |
| 89 | + object_type: "vm" |
| 90 | + destination_folder: "NewFolder/SubFolder" |
| 91 | + delegate_to: localhost |
| 92 | +
|
| 93 | +- name: Move a Host to a different folder |
| 94 | + vcenter_object_move: |
| 95 | + hostname: "{{ vcenter_hostname }}" |
| 96 | + username: "{{ vcenter_username }}" |
| 97 | + password: "{{ vcenter_password }}" |
| 98 | + datacenter: "DC0" |
| 99 | + object_name: "esxi-01" |
| 100 | + object_type: "host" |
| 101 | + destination_folder: "Maintenance" |
| 102 | + delegate_to: localhost |
| 103 | +""" |
| 104 | + |
| 105 | +RETURN = r""" |
| 106 | +changed: |
| 107 | + description: Indicates if the object was moved. |
| 108 | + type: bool |
| 109 | + returned: always |
| 110 | +msg: |
| 111 | + description: A message describing the result. |
| 112 | + type: str |
| 113 | + returned: always |
| 114 | +""" |
| 115 | + |
| 116 | +BASE_FOLDER_MAPPING = { |
| 117 | + "vm": { |
| 118 | + "base_folder_attr": "vmFolder", |
| 119 | + "search_types": [vim.VirtualMachine, vim.Folder, vim.VirtualApp], |
| 120 | + }, |
| 121 | + "host": { |
| 122 | + "base_folder_attr": "hostFolder", |
| 123 | + "search_types": [vim.HostSystem, vim.Folder], |
| 124 | + }, |
| 125 | + "datastore": { |
| 126 | + "base_folder_attr": "datastoreFolder", |
| 127 | + "search_types": [vim.Datastore, vim.Folder], |
| 128 | + }, |
| 129 | + "network": { |
| 130 | + "base_folder_attr": "networkFolder", |
| 131 | + "search_types": [vim.Network, vim.Folder], |
| 132 | + }, |
| 133 | +} |
| 134 | + |
| 135 | + |
| 136 | +# pylint: disable=too-many-instance-attributes |
| 137 | +class ObjectMover(PyVmomi): |
| 138 | + """ |
| 139 | + Helper class to move vCenter inventory objects to a specified destination folder. |
| 140 | + """ |
| 141 | + |
| 142 | + def __init__(self, module): |
| 143 | + """ |
| 144 | + Initialize the ObjectMover, validate parameters, |
| 145 | + and check for the existence of the datacenter, |
| 146 | + the inventory object, and the destination folder. |
| 147 | +
|
| 148 | + :param module: The AnsibleModule instance containing parameters. |
| 149 | + """ |
| 150 | + super().__init__(module) |
| 151 | + self.module = module |
| 152 | + self._vim = vim |
| 153 | + self.datacenter_name = module.params["datacenter"] |
| 154 | + self.inventory_object_name = module.params["object_name"] |
| 155 | + self.inventory_object_type = module.params.get("object_type", "vm") |
| 156 | + self.destination_folder_path = module.params["destination_folder"] |
| 157 | + self.desired_state = module.params.get("state", "present") |
| 158 | + self.vcenter_datacenter_object = find_datacenter_by_name( |
| 159 | + self.content, datacenter_name=self.datacenter_name |
| 160 | + ) |
| 161 | + if not self.vcenter_datacenter_object: |
| 162 | + self.module.fail_json(msg=f"Datacenter '{self.datacenter_name}' not found.") |
| 163 | + if self.inventory_object_type not in BASE_FOLDER_MAPPING: |
| 164 | + self.module.fail_json( |
| 165 | + msg=f"Unsupported object_type '{self.inventory_object_type}'." |
| 166 | + ) |
| 167 | + mapping = BASE_FOLDER_MAPPING[self.inventory_object_type] |
| 168 | + self.inventory_base_folder = getattr( |
| 169 | + self.vcenter_datacenter_object, mapping["base_folder_attr"] |
| 170 | + ) |
| 171 | + self.inventory_search_types = mapping["search_types"] |
| 172 | + self.inventory_object = self._find_object_by_name( |
| 173 | + self.inventory_object_name, |
| 174 | + self.inventory_search_types, |
| 175 | + self.inventory_base_folder, |
| 176 | + ) |
| 177 | + if not self.inventory_object: |
| 178 | + self.module.fail_json( |
| 179 | + msg=( |
| 180 | + f"Object '{self.inventory_object_name}' not found in datacenter " |
| 181 | + f"'{self.datacenter_name}' under branch " |
| 182 | + f"'{self.inventory_object_type}'." |
| 183 | + ) |
| 184 | + ) |
| 185 | + self.destination_folder_object = self._find_destination_folder( |
| 186 | + self.destination_folder_path |
| 187 | + ) |
| 188 | + if not self.destination_folder_object: |
| 189 | + self.module.fail_json( |
| 190 | + msg=( |
| 191 | + f"Destination folder '{self.destination_folder_path}' not found under " |
| 192 | + f"branch '{self.inventory_object_type}' in datacenter " |
| 193 | + f"'{self.datacenter_name}'." |
| 194 | + ) |
| 195 | + ) |
| 196 | + |
| 197 | + def _find_object_by_name(self, name, vim_types, base_folder): |
| 198 | + """ |
| 199 | + Search for an inventory object by its name within the given base folder. |
| 200 | +
|
| 201 | + :param name: Name of the inventory object. |
| 202 | + :param vim_types: List of vSphere types to filter the search. |
| 203 | + :param base_folder: The folder where the search should be performed. |
| 204 | + :return: The inventory object if found, else None. |
| 205 | + """ |
| 206 | + container_view = self.content.viewManager.CreateContainerView( |
| 207 | + base_folder, vim_types, True |
| 208 | + ) |
| 209 | + try: |
| 210 | + for inventory_object in container_view.view: |
| 211 | + if inventory_object.name == name: |
| 212 | + return inventory_object |
| 213 | + return None |
| 214 | + finally: |
| 215 | + container_view.Destroy() |
| 216 | + |
| 217 | + def _find_destination_folder(self, path): |
| 218 | + """ |
| 219 | + Traverse the base folder to locate the destination folder specified by the path. |
| 220 | +
|
| 221 | + :param path: Slash-separated path to the destination folder. |
| 222 | + :return: The destination folder object if found, else None. |
| 223 | + """ |
| 224 | + folder_parts = [part for part in path.strip("/").split("/") if part] |
| 225 | + current_folder = self.inventory_base_folder |
| 226 | + for folder_name in folder_parts: |
| 227 | + child_entities = getattr(current_folder, "childEntity", []) |
| 228 | + if child_entities is None: |
| 229 | + child_entities = [] |
| 230 | + child_folders = { |
| 231 | + child.name: child |
| 232 | + for child in child_entities |
| 233 | + if isinstance(child, vim.Folder) |
| 234 | + } |
| 235 | + if folder_name in child_folders: |
| 236 | + current_folder = child_folders[folder_name] |
| 237 | + else: |
| 238 | + return None |
| 239 | + return current_folder |
| 240 | + |
| 241 | + def move_inventory_object(self): |
| 242 | + """ |
| 243 | + Move the inventory object to the destination folder if it is not already there. |
| 244 | +
|
| 245 | + :return: A tuple with a boolean indicating if a change occurred and a message. |
| 246 | + """ |
| 247 | + parent_obj = getattr(self.inventory_object, "parent", None) |
| 248 | + if parent_obj and getattr(parent_obj, "_moId", None) == getattr( |
| 249 | + self.destination_folder_object, "_moId", None |
| 250 | + ): |
| 251 | + return ( |
| 252 | + False, |
| 253 | + f"Object '{self.inventory_object_name}' is already in the destination folder.", |
| 254 | + ) |
| 255 | + if self.module.check_mode: |
| 256 | + return ( |
| 257 | + True, |
| 258 | + f"Object '{self.inventory_object_name}' would be moved to folder " |
| 259 | + f"'{self.destination_folder_path}'.", |
| 260 | + ) |
| 261 | + try: |
| 262 | + if not isinstance(self.destination_folder_object, vim.Folder): |
| 263 | + self.module.fail_json( |
| 264 | + msg="Destination folder object is not an instance of vim.Folder." |
| 265 | + ) |
| 266 | + return (False, None) |
| 267 | + destination_folder: vim.Folder = cast( |
| 268 | + vim.Folder, self.destination_folder_object |
| 269 | + ) |
| 270 | + move_task = destination_folder.MoveIntoFolder_Task([self.inventory_object]) |
| 271 | + wait_for_task(move_task) |
| 272 | + return ( |
| 273 | + True, |
| 274 | + f"Object '{self.inventory_object_name}' was successfully moved to folder " |
| 275 | + f"'{self.destination_folder_path}'.", |
| 276 | + ) |
| 277 | + except Exception as error: # pylint: disable=broad-exception-caught |
| 278 | + self.module.fail_json( |
| 279 | + msg=( |
| 280 | + f"Failed to move object '{self.inventory_object_name}': " |
| 281 | + f"{to_native(error)}" |
| 282 | + ) |
| 283 | + ) |
| 284 | + return (False, None) |
| 285 | + |
| 286 | + @property |
| 287 | + def vim(self): |
| 288 | + """ |
| 289 | + Property to access the vim module. |
| 290 | +
|
| 291 | + :return: The vim module. |
| 292 | + """ |
| 293 | + return self._vim |
| 294 | + |
| 295 | + |
| 296 | +def main(): |
| 297 | + """ |
| 298 | + Main entry point for the module. Validates parameters, |
| 299 | + performs the move operation, and exits with the result. |
| 300 | + """ |
| 301 | + argument_spec = vmware_argument_spec() |
| 302 | + argument_spec.update( |
| 303 | + { |
| 304 | + "datacenter": { |
| 305 | + "type": "str", |
| 306 | + "required": True, |
| 307 | + "aliases": ["datacenter_name"], |
| 308 | + }, |
| 309 | + "object_name": {"type": "str", "required": True}, |
| 310 | + "object_type": { |
| 311 | + "type": "str", |
| 312 | + "default": "vm", |
| 313 | + "choices": ["vm", "host", "datastore", "network"], |
| 314 | + }, |
| 315 | + "destination_folder": {"type": "str", "required": True}, |
| 316 | + "state": {"type": "str", "default": "present", "choices": ["present"]}, |
| 317 | + } |
| 318 | + ) |
| 319 | + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) |
| 320 | + object_mover = ObjectMover(module) |
| 321 | + result = object_mover.move_inventory_object() |
| 322 | + if result is None: |
| 323 | + module.exit_json(changed=False, msg="Unexpected error: No result returned.") |
| 324 | + changed, result_message = result |
| 325 | + module.exit_json(changed=changed, msg=result_message) |
| 326 | + |
| 327 | + |
| 328 | +if __name__ == "__main__": |
| 329 | + main() |
0 commit comments