Skip to content

Commit bba9879

Browse files
committed
feat(vcenter_object_move): add module to move vCenter inventory objects to specified folder
1 parent 2712c53 commit bba9879

File tree

4 files changed

+378
-0
lines changed

4 files changed

+378
-0
lines changed
+329
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
cloud/vcenter
2+
needs/target/prepare_vmware_tests
3+
zuul/vmware/vcenter_1esxi
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
vcenter_object_move_new_network_folder: NewNetworkFolder
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright: (c) 2025, Simon Bärlocher (@sbaerlocher) <s.baerlocher@sbaerlocher.ch>
2+
# Copyright: (c) 2025, whatwedo GmbH (https://whatwedo.ch)
3+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
4+
5+
- import_role:
6+
name: prepare_vmware_tests
7+
vars:
8+
setup_attach_host: true
9+
setup_datastore: true
10+
setup_dvswitch: true
11+
setup_resource_pool: true
12+
setup_virtualmachines: true
13+
setup_switch: true
14+
setup_dvs_portgroup: true
15+
16+
- name: Create new network folder
17+
vcenter_folder:
18+
hostname: "{{ vcenter_hostname }}"
19+
username: "{{ vcenter_username }}"
20+
password: "{{ vcenter_password }}"
21+
validate_certs: false
22+
datacenter: "{{ dc1 }}"
23+
folder_name: "{{ vcenter_object_move_new_network_folder }}"
24+
folder_type: network
25+
state: present
26+
register: folder_result
27+
28+
- name: Move the port group into the new network folder
29+
vcenter_object_move:
30+
hostname: "{{ vcenter_hostname }}"
31+
username: "{{ vcenter_username }}"
32+
password: "{{ vcenter_password }}"
33+
validate_certs: false
34+
datacenter: "{{ dc1 }}"
35+
object_name: "{{ dvpg1 }}"
36+
object_type: network
37+
destination_folder: "{{ vcenter_object_move_new_network_folder }}"
38+
state: present
39+
register: move_result
40+
41+
- name: Assert that the port group was successfully moved or already in the folder
42+
assert:
43+
that:
44+
- "'successfully moved' in move_result.msg or 'already in the destination folder' in move_result.msg"

0 commit comments

Comments
 (0)