diff --git a/lib/ansible/modules/cloud/vmware/vsphere_file.py b/lib/ansible/modules/cloud/vmware/vsphere_file.py new file mode 100644 index 00000000000..a627d808143 --- /dev/null +++ b/lib/ansible/modules/cloud/vmware/vsphere_file.py @@ -0,0 +1,355 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: vsphere_file +short_description: Manage files on a vCenter datastore +description: +- Manage files on a vCenter datastore. +version_added: '2.8' +author: +- Dag Wieers (@dagwieers) +options: + host: + description: + - The vCenter server on which the datastore is available. + type: str + required: true + aliases: [ hostname ] + username: + description: + - The user name to authenticate on the vCenter server. + type: str + required: true + password: + description: + - The password to authenticate on the vCenter server. + type: str + required: true + datacenter: + description: + - The datacenter on the vCenter server that holds the datastore. + type: str + required: true + datastore: + description: + - The datastore on the vCenter server to push files to. + type: str + required: true + path: + description: + - The file or directory on the datastore on the vCenter server. + type: str + required: true + aliases: [ dest ] + validate_certs: + description: + - If C(no), SSL certificates will not be validated. This should only be + set to C(no) when no other option exists. + type: bool + default: yes + timeout: + description: + - The timeout in seconds for the upload to the datastore. + type: int + default: 10 + state: + description: + - The state of or the action on the provided path. + - If C(absent), the file will be removed. + - If C(directory), the directory will be created. + - If C(file), more information of the (existing) file will be returned. + - If C(touch), an empty file will be created if the path does not exist. + type: str + choices: [ absent, directory, file, touch ] + default: file +notes: +- The vSphere folder API does not allow to remove directory objects. +''' + +EXAMPLES = r''' +- name: Create an empty file on a datastore + vsphere_file: + host: '{{ vhost }}' + username: '{{ vuser }}' + password: '{{ vpass }}' + datacenter: DC1 Someplace + datastore: datastore1 + path: some/remote/file + state: touch + delegate_to: localhost + +- name: Create a directory on a datastore + vsphere_copy: + host: '{{ vhost }}' + username: '{{ vuser }}' + password: '{{ vpass }}' + src: /other/local/file + datacenter: DC2 Someplace + datastore: datastore2 + path: other/remote/file + state: directory + delegate_to: localhost + +- name: Query a file on a datastore + vsphere_file: + host: '{{ vhost }}' + username: '{{ vuser }}' + password: '{{ vpass }}' + datacenter: DC1 Someplace + datastore: datastore1 + path: some/remote/file + state: touch + delegate_to: localhost + ignore_errors: yes + +- name: Delete a file on a datastore + vsphere_copy: + host: '{{ vhost }}' + username: '{{ vuser }}' + password: '{{ vpass }}' + datacenter: DC2 Someplace + datastore: datastore2 + path: other/remote/file + state: absent + delegate_to: localhost +''' + +RETURN = r''' +''' + +import socket +import sys + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import PY2 +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.six.moves.urllib.parse import quote, urlencode +from ansible.module_utils.urls import open_url +from ansible.module_utils._text import to_native + + +def vmware_path(datastore, datacenter, path): + ''' Constructs a URL path that VSphere accepts reliably ''' + path = '/folder/{path}'.format(path=quote(path.strip('/'))) + # Due to a software bug in vSphere, it fails to handle ampersand in datacenter names + # The solution is to do what vSphere does (when browsing) and double-encode ampersands, maybe others ? + datacenter = datacenter.replace('&', '%26') + if not path.startswith('/'): + path = '/' + path + params = dict(dsName=datastore) + if datacenter: + params['dcPath'] = datacenter + return '{0}?{1}'.format(path, urlencode(params)) + + +def main(): + + module = AnsibleModule( + argument_spec=dict( + host=dict(type='str', required=True, aliases=['hostname']), + username=dict(type='str', required=True), + password=dict(type='str', required=True, no_log=True), + datacenter=dict(type='str', required=True), + datastore=dict(type='str', required=True), + path=dict(type='str', required=True, aliases=['dest']), + state=dict(type='str', default='file', choices=['absent', 'directory', 'file', 'touch']), + timeout=dict(type='int', default=10), + validate_certs=dict(type='bool', default=True), + ), + supports_check_mode=True, + ) + + host = module.params.get('host') + username = module.params.get('username') + password = module.params.get('password') + src = module.params.get('src') + datacenter = module.params.get('datacenter') + datastore = module.params.get('datastore') + path = module.params.get('path') + validate_certs = module.params.get('validate_certs') + timeout = module.params.get('timeout') + state = module.params.get('state') + + remote_path = vmware_path(datastore, datacenter, path) + url = 'https://%s%s' % (host, remote_path) + + result = dict( + path=path, + size=None, + state=state, + status=None, + url=url, + ) + + # Check if the file/directory exists + try: + r = open_url(url, method='HEAD', timeout=timeout, + url_username=username, url_password=password, + validate_certs=validate_certs, force_basic_auth=True) + except HTTPError as e: + r = e + except socket.error as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + except Exception as e: + module.fail_json(msg=to_native(e), errno=dir(e), reason=to_native(e), **result) + + if PY2: + sys.exc_clear() # Avoid false positive traceback in fail_json() on Python 2 + + status = r.getcode() + if status == 200: + exists = True + result['size'] = int(r.headers.get('content-length', None)) + elif status == 404: + exists = False + else: + result['reason'] = r.msg + result['status'] = status + module.fail_json(msg="Failed to query for file '%s'" % path, errno=None, headers=dict(r.headers), **result) + + if state == 'absent': + if not exists: + module.exit_json(changed=False, **result) + + if module.check_mode: + result['reason'] = 'No Content' + result['status'] = 204 + else: + try: + r = open_url(url, method='DELETE', timeout=timeout, + url_username=username, url_password=password, + validate_certs=validate_certs, force_basic_auth=True) + except HTTPError as e: + r = e + except socket.error as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + except Exception as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + + if PY2: + sys.exc_clear() # Avoid false positive traceback in fail_json() on Python 2 + + result['reason'] = r.msg + result['status'] = r.getcode() + + if result['status'] == 405: + result['state'] = 'directory' + module.fail_json(msg='Directories cannot be removed with this module', errno=None, headers=dict(r.headers), **result) + elif result['status'] != 204: + module.fail_json(msg="Failed to remove '%s'" % path, errno=None, headers=dict(r.headers), **result) + + result['size'] = None + module.exit_json(changed=True, **result) + + # NOTE: Creating a file in a non-existing directory, then remove the file + elif state == 'directory': + if exists: + module.exit_json(changed=False, **result) + + if module.check_mode: + result['reason'] = 'Created' + result['status'] = 201 + else: + # Create a temporary file in the new directory + remote_path = vmware_path(datastore, datacenter, path + '/foobar.tmp') + temp_url = 'https://%s%s' % (host, remote_path) + + try: + r = open_url(temp_url, method='PUT', timeout=timeout, + url_username=username, url_password=password, + validate_certs=validate_certs, force_basic_auth=True) + except HTTPError as e: + r = e + except socket.error as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + except Exception as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + + if PY2: + sys.exc_clear() # Avoid false positive traceback in fail_json() on Python 2 + + result['reason'] = r.msg + result['status'] = r.getcode() + if result['status'] != 201: + result['url'] = temp_url + module.fail_json(msg='Failed to create temporary file', errno=None, headers=dict(r.headers), **result) + + try: + r = open_url(temp_url, method='DELETE', timeout=timeout, + url_username=username, url_password=password, + validate_certs=validate_certs, force_basic_auth=True) + except HTTPError as e: + r = e + except socket.error as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + except Exception as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + + if PY2: + sys.exc_clear() # Avoid false positive traceback in fail_json() on Python 2 + + status = r.getcode() + if status != 204: + result['reason'] = r.msg + result['status'] = status + module.warn('Failed to remove temporary file ({reason})'.format(**result)) + + module.exit_json(changed=True, **result) + + elif state == 'file': + + if not exists: + result['state'] = 'absent' + result['status'] = status + module.fail_json(msg="File '%s' is absent, cannot continue" % path, **result) + + result['status'] = status + module.exit_json(changed=False, **result) + + elif state == 'touch': + if exists: + result['state'] = 'file' + module.exit_json(changed=False, **result) + + if module.check_mode: + result['reason'] = 'Created' + result['status'] = 201 + else: + try: + r = open_url(url, method='PUT', timeout=timeout, + url_username=username, url_password=password, + validate_certs=validate_certs, force_basic_auth=True) + except HTTPError as e: + r = e + except socket.error as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + except Exception as e: + module.fail_json(msg=to_native(e), errno=e[0], reason=to_native(e), **result) + + if PY2: + sys.exc_clear() # Avoid false positive traceback in fail_json() on Python 2 + + result['reason'] = r.msg + result['status'] = r.getcode() + if result['status'] != 201: + module.fail_json(msg="Failed to touch '%s'" % path, errno=None, headers=dict(r.headers), **result) + + result['size'] = 0 + result['state'] = 'file' + module.exit_json(changed=True, **result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/vsphere_file/aliases b/test/integration/targets/vsphere_file/aliases new file mode 100644 index 00000000000..ad7ccf7ada2 --- /dev/null +++ b/test/integration/targets/vsphere_file/aliases @@ -0,0 +1 @@ +unsupported diff --git a/test/integration/targets/vsphere_file/tasks/main.yml b/test/integration/targets/vsphere_file/tasks/main.yml new file mode 100644 index 00000000000..d3b0284b323 --- /dev/null +++ b/test/integration/targets/vsphere_file/tasks/main.yml @@ -0,0 +1,369 @@ +- set_fact: + file: '/ansible_test_file.txt' + directory: '/ansible_test_directory/' + vsphere_connection: &vsphere_conn + host: '{{ vcenter_ipaddress }}' + username: '{{ vcenter_username }}' + password: '{{ vcenter_password }}' + datacenter: '{{ vcenter_datacenter }}' + datastore: '{{ vcenter_datastore }}' + validate_certs: false + +- set_fact: + vsphere_conection_file: &vsphere_conn_file + <<: *vsphere_conn + path: '{{ file }}' + vsphere_conection_dir: &vsphere_conn_dir + <<: *vsphere_conn + path: '{{ directory }}' + +# Clean up environment +- name: Delete file + vsphere_file: + <<: *vsphere_conn_file + state: absent + ignore_errors: true + +- name: Delete directory + vsphere_file: + <<: *vsphere_conn_dir + state: absent + ignore_errors: true + +# Test file operations +- name: Test file at start (check_mode) + vsphere_file: + <<: *vsphere_conn_file + state: file + check_mode: true + ignore_errors: true + register: cm_test_file_start + +- name: Verify cm_test_file_start + assert: + that: + - cm_test_file_start is failed + - cm_test_file_start.state == 'absent' + - cm_test_file_start.status == 404 + +- name: Test file at start (normal mode) + vsphere_file: + <<: *vsphere_conn_file + state: file + register: nm_test_file_start + ignore_errors: true + +- name: Verify nm_test_file_start + assert: + that: + - nm_test_file_start is failed + - nm_test_file_start.state == 'absent' + - nm_test_file_start.status == 404 + +- name: Touch file (check_mode) + vsphere_file: + <<: *vsphere_conn_file + state: touch + check_mode: true + register: cm_touch_file + +- name: Verify cm_touch_file + assert: + that: + - cm_touch_file is success + - cm_touch_file is changed + - cm_touch_file.reason == 'Created' + - cm_touch_file.size == 0 + #- cm_touch_file.state == 'file' # FIXME + - cm_touch_file.status == 201 + +- name: Touch file (normal mode) + vsphere_file: + <<: *vsphere_conn_file + state: touch + register: nm_touch_file + +- name: Verify nm_touch_file + assert: + that: + - nm_touch_file is success + - nm_touch_file is changed + - nm_touch_file.reason == 'Created' + - nm_touch_file.size == 0 + #- nm_touch_file.state == 'file' # FIXME + - nm_touch_file.status == 201 + +- name: Test file after touch (check_mode) + vsphere_file: + <<: *vsphere_conn_file + state: file + check_mode: true + register: cm_test_file_touch + +- name: Verify cm_test_file_touch + assert: + that: + - cm_test_file_touch is success + - cm_test_file_touch is not changed + - cm_test_file_touch.size == 0 + #- cm_test_file_touch.state == 'file' # FIXME + - cm_test_file_touch.status == 200 + +- name: Test file after touch (normal mode) + vsphere_file: + <<: *vsphere_conn_file + state: file + register: nm_test_file_touch + +- name: Verify nm_test_file_touch + assert: + that: + - nm_test_file_touch is success + - nm_test_file_touch is not changed + - nm_test_file_touch.size == 0 + #- nm_test_file_touch.state == 'file' # FIXME + - nm_test_file_touch.status == 200 + +- name: Delete file (check_mode) + vsphere_file: + <<: *vsphere_conn_file + state: absent + check_mode: true + register: cm_delete_file + +- name: Verify cm_delete_file + assert: + that: + - cm_delete_file is success + - cm_delete_file is changed + - cm_delete_file.reason == 'No Content' + - cm_delete_file.size == None + - cm_delete_file.state == 'absent' + - cm_delete_file.status == 204 + +- name: Delete file (normal mode) + vsphere_file: + <<: *vsphere_conn_file + state: absent + register: nm_delete_file + +- name: Verify nm_delete_file + assert: + that: + - nm_delete_file is success + - nm_delete_file is changed + - nm_delete_file.reason == 'No Content' + - nm_delete_file.size == None + - nm_delete_file.state == 'absent' + - nm_delete_file.status == 204 + +- name: Test file after delete (check_mode) + vsphere_file: + <<: *vsphere_conn_file + state: file + check_mode: true + ignore_errors: true + register: cm_test_file_delete + +- name: Verify cm_test_file_delete + assert: + that: + - cm_test_file_delete is failed + - cm_test_file_delete.size == None + - cm_test_file_delete.state == 'absent' + - cm_test_file_delete.status == 404 + +- name: Test file after delete (normal mode) + vsphere_file: + <<: *vsphere_conn_file + state: file + ignore_errors: true + register: nm_test_file_delete + +- name: Verify nm_test_file_delete + assert: + that: + - nm_test_file_delete is failed + - nm_test_file_delete.size == None + - nm_test_file_delete.state == 'absent' + - nm_test_file_delete.status == 404 + +# Test directory operations +- name: Test directory at start (check_mode) + vsphere_file: + <<: *vsphere_conn_dir + state: file + check_mode: true + ignore_errors: true + register: cm_test_dir_start + +- name: Verify cm_test_dir_start + assert: + that: + - cm_test_dir_start is failed + - cm_test_dir_start.size == None + - cm_test_dir_start.state == 'absent' + - cm_test_dir_start.status == 404 + +- name: Test directory at start (normal mode) + vsphere_file: + <<: *vsphere_conn_dir + state: file + ignore_errors: true + register: nm_test_dir_start + +# NOTE: Deleting directories is not implemented. +- name: Verify nm_test_dir_start + assert: + that: + - nm_test_dir_start is failed + - nm_test_dir_start.size == None + - nm_test_dir_start.state == 'absent' + - nm_test_dir_start.status == 404 + +- name: Create directory (check_mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: directory + check_mode: true + register: cm_create_dir + +- name: Verify cm_create_dir + assert: + that: + - cm_create_dir is success + - cm_create_dir is changed + - cm_create_dir.reason == 'Created' + - cm_create_dir.size == None + #- cm_create_dir.state == 'directory' # FIXME + - cm_create_dir.status == 201 + +- name: Create directory (normal mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: directory + register: nm_create_dir + +- name: Verify nm_create_dir + assert: + that: + - nm_create_dir is success + - nm_create_dir is changed + - nm_create_dir.reason == 'Created' + - nm_create_dir.size == None + #- nm_create_dir.state == 'directory' # FIXME + - nm_create_dir.status == 201 + +- name: Test directory after create (check_mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: file + check_mode: true + register: cm_test_dir_create + +- name: Verify cm_test_dir_create + assert: + that: + - cm_test_dir_create is success + - cm_test_dir_create is not changed + #- cm_test_dir_create.size == 0 + #- cm_test_dir_create.state == 'file' # FIXME + - cm_test_dir_create.status == 200 + +- name: Test directory after create (normal mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: file + register: nm_test_dir_create + +- name: Verify nm_test_dir_create + assert: + that: + - nm_test_dir_create is success + - nm_test_dir_create is not changed + #- nm_test_dir_create.size == 0 + #- nm_test_dir_create.state == 'file' # FIXME + - nm_test_dir_create.status == 200 + +- name: Delete directory (check_mode) + vsphere_file: + <<: *vsphere_conn_dir + state: absent + check_mode: true + ignore_errors: true + register: cm_delete_dir + +- name: Verify cm_delete_dir + assert: + that: + - cm_delete_dir is success + - cm_delete_dir is changed + - cm_delete_dir.reason == 'No Content' + - cm_delete_dir.size == None + - cm_delete_dir.state == 'absent' + - cm_delete_dir.status == 204 + +- name: Delete directory (normal mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: absent + ignore_errors: true + register: nm_delete_dir + +# NOTE: Deleting directories is not implemented +- name: Verify nm_delete_dir + assert: + that: + - nm_delete_dir is failed # FIXME + #- nm_delete_dir is success + #- nm_delete_dir is changed + - nm_delete_dir.reason == 'Method Not Allowed' # FIXME + #- cm_delete_dir.reason == 'No Content' + #- nm_delete_dir.size == None + #- cm_delete_dir.state == 'absent' + - nm_delete_dir.status == 405 # FIXME + #- cm_delete_dir.status == 204 + +- name: Test directory after delete (check_mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: file + check_mode: true + ignore_errors: true + register: cm_test_dir_delete + +- name: Verify cm_test_dir_delete + assert: + that: + - cm_test_dir_delete is success # FIXME + - cm_test_dir_delete is not changed #FIXME + #- cm_test_dir_delete is failed + #- cm_test_dir_delete.size == None + #- cm_test_dir_delete.state == 'file' + - cm_test_dir_delete.status == 200 # FIXME + #- nm_test_dir_delete.status == 404 + +- name: Test directory after delete (normal mode) + vsphere_file: + <<: *vsphere_conn_dir + path: '{{ directory }}' + state: file + ignore_errors: true + register: nm_test_dir_delete + +- name: Verify nm_test_dir_delete + assert: + that: + - nm_test_dir_delete is success # FIXME + - nm_test_dir_delete is not changed #FIXME + #- nm_test_dir_delete is failed + #- nm_test_dir_delete.size == None + #- nm_test_dir_delete.state == 'file' + - nm_test_dir_delete.status == 200 # FIXME + #- nm_test_dir_delete.status == 404