1# Copyright: (c) 2018, Deric Crago <deric.crago@gmail.com>
2# Copyright: (c) 2018, Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5from __future__ import absolute_import, division, print_function
6__metaclass__ = type
7
8import re
9from os.path import exists, getsize
10from socket import gaierror
11from ssl import SSLError
12from time import sleep
13import traceback
14
15REQUESTS_IMP_ERR = None
16try:
17    import requests
18    HAS_REQUESTS = True
19except ImportError:
20    REQUESTS_IMP_ERR = traceback.format_exc()
21    HAS_REQUESTS = False
22
23try:
24    from requests.packages import urllib3
25    HAS_URLLIB3 = True
26except ImportError:
27    try:
28        import urllib3
29        HAS_URLLIB3 = True
30    except ImportError:
31        HAS_URLLIB3 = False
32
33from ansible.errors import AnsibleError, AnsibleFileNotFound, AnsibleConnectionFailure
34from ansible.module_utils._text import to_bytes, to_native
35from ansible.plugins.connection import ConnectionBase
36from ansible.module_utils.basic import missing_required_lib
37
38try:
39    from pyVim.connect import Disconnect, SmartConnect, SmartConnectNoSSL
40    from pyVmomi import vim
41
42    HAS_PYVMOMI = True
43except ImportError:
44    HAS_PYVMOMI = False
45    PYVMOMI_IMP_ERR = traceback.format_exc()
46
47
48DOCUMENTATION = """
49    author: Deric Crago <deric.crago@gmail.com>
50    connection: vmware_tools
51    short_description: Execute tasks inside a VM via VMware Tools
52    description:
53      - Use VMware tools to run tasks in, or put/fetch files to guest operating systems running in VMware infrastructure.
54      - In case of Windows VMs, set C(ansible_shell_type) to C(powershell).
55      - Does not work with 'become'.
56    version_added: "2.8"
57    requirements:
58      - pyvmomi (Python library)
59      - requests (Python library)
60    options:
61      vmware_host:
62        description:
63          - FQDN or IP Address for the connection (vCenter or ESXi Host).
64        env:
65          - name: VI_SERVER
66          - name: VMWARE_HOST
67        vars:
68          - name: ansible_host
69          - name: ansible_vmware_host
70        required: True
71      vmware_user:
72        description:
73          - Username for the connection.
74          - "Requires the following permissions on the VM:
75               - VirtualMachine.GuestOperations.Execute
76               - VirtualMachine.GuestOperations.Modify
77               - VirtualMachine.GuestOperations.Query"
78        env:
79          - name: VI_USERNAME
80          - name: VMWARE_USER
81        vars:
82          - name: ansible_vmware_user
83        required: True
84      vmware_password:
85        description:
86          - Password for the connection.
87        env:
88          - name: VI_PASSWORD
89          - name: VMWARE_PASSWORD
90        vars:
91          - name: ansible_vmware_password
92        required: True
93      vmware_port:
94        description:
95          - Port for the connection.
96        env:
97          - name: VI_PORTNUMBER
98          - name: VMWARE_PORT
99        vars:
100          - name: ansible_port
101          - name: ansible_vmware_port
102        required: False
103        default: 443
104      validate_certs:
105        description:
106          - Verify SSL for the connection.
107          - "Note: This will validate certs for both C(vmware_host) and the ESXi host running the VM."
108        env:
109          - name: VMWARE_VALIDATE_CERTS
110        vars:
111          - name: ansible_vmware_validate_certs
112        default: True
113        type: bool
114      vm_path:
115        description:
116          - VM path absolute to the connection.
117          - "vCenter Example: C(Datacenter/vm/Discovered virtual machine/testVM)."
118          - "ESXi Host Example: C(ha-datacenter/vm/testVM)."
119          - Must include VM name, appended to 'folder' as would be passed to M(vmware_guest).
120          - Needs to include I(vm) between the Datacenter and the rest of the VM path.
121          - Datacenter default value for ESXi server is C(ha-datacenter).
122          - Folder I(vm) is not visible in the vSphere Web Client but necessary for VMware API to work.
123        vars:
124          - name: ansible_vmware_guest_path
125        required: True
126      vm_user:
127        description:
128          - VM username.
129        vars:
130          - name: ansible_user
131          - name: ansible_vmware_tools_user
132        required: True
133      vm_password:
134        description:
135          - Password for the user in guest operating system.
136        vars:
137          - name: ansible_password
138          - name: ansible_vmware_tools_password
139        required: True
140      exec_command_sleep_interval:
141        description:
142          - Time in seconds to sleep between execution of command.
143        vars:
144          - name: ansible_vmware_tools_exec_command_sleep_interval
145        default: 0.5
146        type: float
147      file_chunk_size:
148        description:
149          - File chunk size.
150          - "(Applicable when writing a file to disk, example: using the C(fetch) module.)"
151        vars:
152          - name: ansible_vmware_tools_file_chunk_size
153        default: 128
154        type: integer
155      executable:
156        description:
157            - shell to use for execution inside container
158        default: /bin/sh
159        ini:
160          - section: defaults
161            key: executable
162        env:
163          - name: ANSIBLE_EXECUTABLE
164        vars:
165            - name: ansible_executable
166            - name: ansible_vmware_tools_executable
167"""
168
169EXAMPLES = r'''
170# example vars.yml
171---
172ansible_connection: vmware_tools
173
174ansible_vmware_host: vcenter.example.com
175ansible_vmware_user: administrator@vsphere.local
176ansible_vmware_password: Secr3tP4ssw0rd!12
177ansible_vmware_validate_certs: no  # default is yes
178
179# vCenter Connection VM Path Example
180ansible_vmware_guest_path: DATACENTER/vm/FOLDER/{{ inventory_hostname }}
181# ESXi Connection VM Path Example
182ansible_vmware_guest_path: ha-datacenter/vm/{{ inventory_hostname }}
183
184ansible_vmware_tools_user: root
185ansible_vmware_tools_password: MyR00tPassw0rD
186
187# if the target VM guest is Windows set the 'ansible_shell_type' to 'powershell'
188ansible_shell_type: powershell
189
190
191# example playbook_linux.yml
192---
193- name: Test VMware Tools Connection Plugin for Linux
194  hosts: linux
195  tasks:
196    - command: whoami
197
198    - ping:
199
200    - copy:
201        src: foo
202        dest: /home/user/foo
203
204    - fetch:
205        src: /home/user/foo
206        dest: linux-foo
207        flat: yes
208
209    - file:
210        path: /home/user/foo
211        state: absent
212
213
214# example playbook_windows.yml
215---
216- name: Test VMware Tools Connection Plugin for Windows
217  hosts: windows
218  tasks:
219    - win_command: whoami
220
221    - win_ping:
222
223    - win_copy:
224        src: foo
225        dest: C:\Users\user\foo
226
227    - fetch:
228        src: C:\Users\user\foo
229        dest: windows-foo
230        flat: yes
231
232    - win_file:
233        path: C:\Users\user\foo
234        state: absent
235'''
236
237
238class Connection(ConnectionBase):
239    """VMware Tools Connection."""
240
241    transport = "vmware_tools"
242
243    @property
244    def vmware_host(self):
245        """Read-only property holding the connection address."""
246        return self.get_option("vmware_host")
247
248    @property
249    def validate_certs(self):
250        """Read-only property holding whether the connection should validate certs."""
251        return self.get_option("validate_certs")
252
253    @property
254    def authManager(self):
255        """Guest Authentication Manager."""
256        return self._si.content.guestOperationsManager.authManager
257
258    @property
259    def fileManager(self):
260        """Guest File Manager."""
261        return self._si.content.guestOperationsManager.fileManager
262
263    @property
264    def processManager(self):
265        """Guest Process Manager."""
266        return self._si.content.guestOperationsManager.processManager
267
268    @property
269    def windowsGuest(self):
270        """Return if VM guest family is windows."""
271        return self.vm.guest.guestFamily == "windowsGuest"
272
273    def __init__(self, *args, **kwargs):
274        """init."""
275        super(Connection, self).__init__(*args, **kwargs)
276        if hasattr(self, "_shell") and self._shell.SHELL_FAMILY == "powershell":
277            self.module_implementation_preferences = (".ps1", ".exe", "")
278            self.become_methods = ["runas"]
279            self.allow_executable = False
280            self.has_pipelining = False
281            self.allow_extras = True
282
283    def _establish_connection(self):
284        connection_kwargs = {
285            "host": self.vmware_host,
286            "user": self.get_option("vmware_user"),
287            "pwd": self.get_option("vmware_password"),
288            "port": self.get_option("vmware_port"),
289        }
290
291        if self.validate_certs:
292            connect = SmartConnect
293        else:
294            if HAS_URLLIB3:
295                urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
296            connect = SmartConnectNoSSL
297
298        try:
299            self._si = connect(**connection_kwargs)
300        except SSLError:
301            raise AnsibleError("SSL Error: Certificate verification failed.")
302        except (gaierror):
303            raise AnsibleError("Connection Error: Unable to connect to '%s'." % to_native(connection_kwargs["host"]))
304        except vim.fault.InvalidLogin as e:
305            raise AnsibleError("Connection Login Error: %s" % to_native(e.msg))
306
307    def _establish_vm(self):
308        searchIndex = self._si.content.searchIndex
309        self.vm = searchIndex.FindByInventoryPath(self.get_option("vm_path"))
310
311        if self.vm is None:
312            raise AnsibleError("Unable to find VM by path '%s'" % to_native(self.get_option("vm_path")))
313
314        self.vm_auth = vim.NamePasswordAuthentication(
315            username=self.get_option("vm_user"), password=self.get_option("vm_password"), interactiveSession=False
316        )
317
318        try:
319            self.authManager.ValidateCredentialsInGuest(vm=self.vm, auth=self.vm_auth)
320        except vim.fault.InvalidPowerState as e:
321            raise AnsibleError("VM Power State Error: %s" % to_native(e.msg))
322        except vim.fault.RestrictedVersion as e:
323            raise AnsibleError("Restricted Version Error: %s" % to_native(e.msg))
324        except vim.fault.GuestOperationsUnavailable as e:
325            raise AnsibleError("VM Guest Operations (VMware Tools) Error: %s" % to_native(e.msg))
326        except vim.fault.InvalidGuestLogin as e:
327            raise AnsibleError("VM Login Error: %s" % to_native(e.msg))
328        except vim.fault.NoPermission as e:
329            raise AnsibleConnectionFailure("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
330
331    def _connect(self):
332        if not HAS_REQUESTS:
333            raise AnsibleError("%s : %s" % (missing_required_lib('requests'), REQUESTS_IMP_ERR))
334
335        if not HAS_PYVMOMI:
336            raise AnsibleError("%s : %s" % (missing_required_lib('PyVmomi'), PYVMOMI_IMP_ERR))
337
338        super(Connection, self)._connect()
339
340        if self.connected:
341            pass
342
343        self._establish_connection()
344        self._establish_vm()
345
346        self._connected = True
347
348    def close(self):
349        """Close connection."""
350        super(Connection, self).close()
351
352        Disconnect(self._si)
353        self._connected = False
354
355    def reset(self):
356        """Reset the connection."""
357        super(Connection, self).reset()
358
359        self.close()
360        self._connect()
361
362    def create_temporary_file_in_guest(self, prefix="", suffix=""):
363        """Create a temporary file in the VM."""
364        try:
365            return self.fileManager.CreateTemporaryFileInGuest(vm=self.vm, auth=self.vm_auth, prefix=prefix, suffix=suffix)
366        except vim.fault.NoPermission as e:
367            raise AnsibleError("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
368
369    def _get_program_spec_program_path_and_arguments(self, cmd):
370        if self.windowsGuest:
371            '''
372            we need to warp the execution of powershell into a cmd /c because
373            the call otherwise fails with "Authentication or permission failure"
374            #FIXME: Fix the unecessary invocation of cmd and run the command directly
375            '''
376            program_path = "cmd.exe"
377            arguments = "/c %s" % cmd
378        else:
379            program_path = self.get_option("executable")
380            arguments = re.sub(r"^%s\s*" % program_path, "", cmd)
381
382        return program_path, arguments
383
384    def _get_guest_program_spec(self, cmd, stdout, stderr):
385        guest_program_spec = vim.GuestProgramSpec()
386
387        program_path, arguments = self._get_program_spec_program_path_and_arguments(cmd)
388
389        arguments += " 1> %s 2> %s" % (stdout, stderr)
390
391        guest_program_spec.programPath = program_path
392        guest_program_spec.arguments = arguments
393
394        return guest_program_spec
395
396    def _get_pid_info(self, pid):
397        try:
398            processes = self.processManager.ListProcessesInGuest(vm=self.vm, auth=self.vm_auth, pids=[pid])
399        except vim.fault.NoPermission as e:
400            raise AnsibleError("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
401        return processes[0]
402
403    def _fix_url_for_hosts(self, url):
404        """
405        Fix url if connection is a host.
406
407        The host part of the URL is returned as '*' if the hostname to be used is the name of the server to which the call was made. For example, if the call is
408        made to esx-svr-1.domain1.com, and the file is available for download from http://esx-svr-1.domain1.com/guestFile?id=1&token=1234, the URL returned may
409        be http://*/guestFile?id=1&token=1234. The client replaces the asterisk with the server name on which it invoked the call.
410
411        https://code.vmware.com/apis/358/vsphere#/doc/vim.vm.guest.FileManager.FileTransferInformation.html
412        """
413        return url.replace("*", self.vmware_host)
414
415    def _fetch_file_from_vm(self, guestFilePath):
416        try:
417            fileTransferInformation = self.fileManager.InitiateFileTransferFromGuest(vm=self.vm, auth=self.vm_auth, guestFilePath=guestFilePath)
418        except vim.fault.NoPermission as e:
419            raise AnsibleError("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
420
421        url = self._fix_url_for_hosts(fileTransferInformation.url)
422        response = requests.get(url, verify=self.validate_certs, stream=True)
423
424        if response.status_code != 200:
425            raise AnsibleError("Failed to fetch file")
426
427        return response
428
429    def delete_file_in_guest(self, filePath):
430        """Delete file from VM."""
431        try:
432            self.fileManager.DeleteFileInGuest(vm=self.vm, auth=self.vm_auth, filePath=filePath)
433        except vim.fault.NoPermission as e:
434            raise AnsibleError("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
435
436    def exec_command(self, cmd, in_data=None, sudoable=True):
437        """Execute command."""
438        super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable)
439
440        stdout = self.create_temporary_file_in_guest(suffix=".stdout")
441        stderr = self.create_temporary_file_in_guest(suffix=".stderr")
442
443        guest_program_spec = self._get_guest_program_spec(cmd, stdout, stderr)
444
445        try:
446            pid = self.processManager.StartProgramInGuest(vm=self.vm, auth=self.vm_auth, spec=guest_program_spec)
447        except vim.fault.NoPermission as e:
448            raise AnsibleError("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
449        except vim.fault.FileNotFound as e:
450            raise AnsibleError("StartProgramInGuest Error: %s" % to_native(e.msg))
451
452        pid_info = self._get_pid_info(pid)
453
454        while pid_info.endTime is None:
455            sleep(self.get_option("exec_command_sleep_interval"))
456            pid_info = self._get_pid_info(pid)
457
458        stdout_response = self._fetch_file_from_vm(stdout)
459        self.delete_file_in_guest(stdout)
460
461        stderr_response = self._fetch_file_from_vm(stderr)
462        self.delete_file_in_guest(stderr)
463
464        return pid_info.exitCode, stdout_response.text, stderr_response.text
465
466    def fetch_file(self, in_path, out_path):
467        """Fetch file."""
468        super(Connection, self).fetch_file(in_path, out_path)
469
470        in_path_response = self._fetch_file_from_vm(in_path)
471
472        with open(out_path, "wb") as fd:
473            for chunk in in_path_response.iter_content(chunk_size=self.get_option("file_chunk_size")):
474                fd.write(chunk)
475
476    def put_file(self, in_path, out_path):
477        """Put file."""
478        super(Connection, self).put_file(in_path, out_path)
479
480        if not exists(to_bytes(in_path, errors="surrogate_or_strict")):
481            raise AnsibleFileNotFound("file or module does not exist: '%s'" % to_native(in_path))
482
483        try:
484            put_url = self.fileManager.InitiateFileTransferToGuest(
485                vm=self.vm, auth=self.vm_auth, guestFilePath=out_path, fileAttributes=vim.GuestFileAttributes(), fileSize=getsize(in_path), overwrite=True
486            )
487        except vim.fault.NoPermission as e:
488            raise AnsibleError("No Permission Error: %s %s" % (to_native(e.msg), to_native(e.privilegeId)))
489
490        url = self._fix_url_for_hosts(put_url)
491
492        # file size of 'in_path' must be greater than 0
493        with open(in_path, "rb") as fd:
494            response = requests.put(url, verify=self.validate_certs, data=fd)
495
496        if response.status_code != 200:
497            raise AnsibleError("File transfer failed")
498