From dec81c6ccd59bcf8f59511013d1a8f2a7b385921 Mon Sep 17 00:00:00 2001 From: alexander <79072457+abakanovskii@users.noreply.github.com> Date: Sat, 5 Oct 2024 16:02:01 +0300 Subject: [PATCH] one_image/one_image_info: refactor (#8889) * Refactor one_image * Refactor one_image_info * Add examples one_image * Add CHANGELOG fragment * Add integration tests for one_image * Add integration tests for one_image_info * Update one_image DOC * Update one_image_info DOC * Update one_image DOC * Update one_image_info DOC * Fix f-strings for one_image * Update CHANGELOG fragment * PR fixes * PR fixes (cherry picked from commit fea0ffa5aa8d90a01616e596e03c6e78fb3f887c) --- .../8889-refactor-one-image-modules.yml | 6 + plugins/module_utils/opennebula.py | 88 +++ plugins/modules/one_image.py | 580 +++++++++++------- plugins/modules/one_image_info.py | 377 +++++++----- tests/integration/targets/one_image/aliases | 7 + .../targets/one_image/tasks/main.yml | 210 +++++++ .../targets/one_image_info/aliases | 7 + .../targets/one_image_info/tasks/main.yml | 192 ++++++ 8 files changed, 1107 insertions(+), 360 deletions(-) create mode 100644 changelogs/fragments/8889-refactor-one-image-modules.yml create mode 100644 tests/integration/targets/one_image/aliases create mode 100644 tests/integration/targets/one_image/tasks/main.yml create mode 100644 tests/integration/targets/one_image_info/aliases create mode 100644 tests/integration/targets/one_image_info/tasks/main.yml diff --git a/changelogs/fragments/8889-refactor-one-image-modules.yml b/changelogs/fragments/8889-refactor-one-image-modules.yml new file mode 100644 index 00000000000..de552c17a6b --- /dev/null +++ b/changelogs/fragments/8889-refactor-one-image-modules.yml @@ -0,0 +1,6 @@ +minor_changes: + - one_image - add option ``persistent`` to manage image persistence (https://github.com/ansible-collections/community.general/issues/3578, https://github.com/ansible-collections/community.general/pull/8889). + - one_image - refactor code to make it more similar to ``one_template`` and ``one_vnet`` (https://github.com/ansible-collections/community.general/pull/8889). + - one_image_info - refactor code to make it more similar to ``one_template`` and ``one_vnet`` (https://github.com/ansible-collections/community.general/pull/8889). + - one_image - extend xsd scheme to make it return a lot more info about image (https://github.com/ansible-collections/community.general/pull/8889). + - one_image_info - extend xsd scheme to make it return a lot more info about image (https://github.com/ansible-collections/community.general/pull/8889). diff --git a/plugins/module_utils/opennebula.py b/plugins/module_utils/opennebula.py index 94732e4f7c3..24833350c65 100644 --- a/plugins/module_utils/opennebula.py +++ b/plugins/module_utils/opennebula.py @@ -16,6 +16,7 @@ from ansible.module_utils.basic import AnsibleModule +IMAGE_STATES = ['INIT', 'READY', 'USED', 'DISABLED', 'LOCKED', 'ERROR', 'CLONE', 'DELETE', 'USED_PERS', 'LOCKED_USED', 'LOCKED_USED_PERS'] HAS_PYONE = True try: @@ -347,3 +348,90 @@ def run(self, one, module, result): result: the Ansible result """ raise NotImplementedError("Method requires implementation") + + def get_image_list_id(self, image, element): + """ + This is a helper function for get_image_info to iterate over a simple list of objects + """ + list_of_id = [] + + if element == 'VMS': + image_list = image.VMS + if element == 'CLONES': + image_list = image.CLONES + if element == 'APP_CLONES': + image_list = image.APP_CLONES + + for iter in image_list.ID: + list_of_id.append( + # These are optional so firstly check for presence + getattr(iter, 'ID', 'Null'), + ) + return list_of_id + + def get_image_snapshots_list(self, image): + """ + This is a helper function for get_image_info to iterate over a dictionary + """ + list_of_snapshots = [] + + for iter in image.SNAPSHOTS.SNAPSHOT: + list_of_snapshots.append({ + 'date': iter['DATE'], + 'parent': iter['PARENT'], + 'size': iter['SIZE'], + # These are optional so firstly check for presence + 'allow_orhans': getattr(image.SNAPSHOTS, 'ALLOW_ORPHANS', 'Null'), + 'children': getattr(iter, 'CHILDREN', 'Null'), + 'active': getattr(iter, 'ACTIVE', 'Null'), + 'name': getattr(iter, 'NAME', 'Null'), + }) + return list_of_snapshots + + def get_image_info(self, image): + """ + This method is used by one_image and one_image_info modules to retrieve + information from XSD scheme of an image + Returns: a copy of the parameters that includes the resolved parameters. + """ + info = { + 'id': image.ID, + 'name': image.NAME, + 'state': IMAGE_STATES[image.STATE], + 'running_vms': image.RUNNING_VMS, + 'used': bool(image.RUNNING_VMS), + 'user_name': image.UNAME, + 'user_id': image.UID, + 'group_name': image.GNAME, + 'group_id': image.GID, + 'permissions': { + 'owner_u': image.PERMISSIONS.OWNER_U, + 'owner_m': image.PERMISSIONS.OWNER_M, + 'owner_a': image.PERMISSIONS.OWNER_A, + 'group_u': image.PERMISSIONS.GROUP_U, + 'group_m': image.PERMISSIONS.GROUP_M, + 'group_a': image.PERMISSIONS.GROUP_A, + 'other_u': image.PERMISSIONS.OTHER_U, + 'other_m': image.PERMISSIONS.OTHER_M, + 'other_a': image.PERMISSIONS.OTHER_A + }, + 'type': image.TYPE, + 'disk_type': image.DISK_TYPE, + 'persistent': image.PERSISTENT, + 'regtime': image.REGTIME, + 'source': image.SOURCE, + 'path': image.PATH, + 'fstype': getattr(image, 'FSTYPE', 'Null'), + 'size': image.SIZE, + 'cloning_ops': image.CLONING_OPS, + 'cloning_id': image.CLONING_ID, + 'target_snapshot': image.TARGET_SNAPSHOT, + 'datastore_id': image.DATASTORE_ID, + 'datastore': image.DATASTORE, + 'vms': self.get_image_list_id(image, 'VMS'), + 'clones': self.get_image_list_id(image, 'CLONES'), + 'app_clones': self.get_image_list_id(image, 'APP_CLONES'), + 'snapshots': self.get_image_snapshots_list(image), + 'template': image.TEMPLATE, + } + return info diff --git a/plugins/modules/one_image.py b/plugins/modules/one_image.py index a0081a0fe04..86db3b04050 100644 --- a/plugins/modules/one_image.py +++ b/plugins/modules/one_image.py @@ -17,6 +17,7 @@ requirements: - pyone extends_documentation_fragment: + - community.general.opennebula - community.general.attributes attributes: check_mode: @@ -24,23 +25,6 @@ diff_mode: support: none options: - api_url: - description: - - URL of the OpenNebula RPC server. - - It is recommended to use HTTPS so that the username/password are not - - transferred over the network unencrypted. - - If not set then the value of the E(ONE_URL) environment variable is used. - type: str - api_username: - description: - - Name of the user to login into the OpenNebula RPC server. If not set - - then the value of the E(ONE_USERNAME) environment variable is used. - type: str - api_password: - description: - - Password of the user to login into OpenNebula RPC server. If not set - - then the value of the E(ONE_PASSWORD) environment variable is used. - type: str id: description: - A O(id) of the image you would like to manage. @@ -67,6 +51,11 @@ - A name that will be assigned to the existing or new image. - In the case of cloning, by default O(new_name) will take the name of the origin image with the prefix 'Copy of'. type: str + persistent: + description: + - Whether the image should be persistent or non-persistent. + type: bool + version_added: 9.5.0 author: - "Milan Ilic (@ilicmilan)" ''' @@ -92,6 +81,11 @@ id: 37 enabled: false +- name: Make the IMAGE persistent + community.general.one_image: + id: 37 + persistent: true + - name: Enable the IMAGE by name community.general.one_image: name: bar-image @@ -114,300 +108,448 @@ id: description: image id type: int - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: 153 name: description: image name type: str - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: app1 group_id: description: image's group id type: int - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: 1 group_name: description: image's group name type: str - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: one-users owner_id: description: image's owner id type: int - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: 143 owner_name: description: image's owner name type: str - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: ansible-test state: description: state of image instance type: str - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: READY used: description: is image in use type: bool - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: true running_vms: description: count of running vms that use this image type: int - returned: success + returned: when O(state=present), O(state=cloned), or O(state=renamed) sample: 7 +permissions: + description: The image's permissions. + type: dict + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 + contains: + owner_u: + description: The image's owner USAGE permissions. + type: str + sample: 1 + owner_m: + description: The image's owner MANAGE permissions. + type: str + sample: 0 + owner_a: + description: The image's owner ADMIN permissions. + type: str + sample: 0 + group_u: + description: The image's group USAGE permissions. + type: str + sample: 0 + group_m: + description: The image's group MANAGE permissions. + type: str + sample: 0 + group_a: + description: The image's group ADMIN permissions. + type: str + sample: 0 + other_u: + description: The image's other users USAGE permissions. + type: str + sample: 0 + other_m: + description: The image's other users MANAGE permissions. + type: str + sample: 0 + other_a: + description: The image's other users ADMIN permissions + type: str + sample: 0 + sample: + owner_u: 1 + owner_m: 0 + owner_a: 0 + group_u: 0 + group_m: 0 + group_a: 0 + other_u: 0 + other_m: 0 + other_a: 0 +type: + description: The image's type. + type: str + sample: 0 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +disk_type: + description: The image's format type. + type: str + sample: 0 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +persistent: + description: The image's persistence status (1 means true, 0 means false). + type: int + sample: 1 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +source: + description: The image's source. + type: str + sample: /var/lib/one//datastores/100/somerandomstringxd + returned: when O(state=present), O(state=cloned), or O(state=renamed) +path: + description: The image's filesystem path. + type: str + sample: /var/tmp/hello.qcow2 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +fstype: + description: The image's filesystem type. + type: str + sample: ext4 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +size: + description: The image's size in MegaBytes. + type: int + sample: 10000 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +cloning_ops: + description: The image's cloning operations per second. + type: int + sample: 0 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +cloning_id: + description: The image's cloning ID. + type: int + sample: -1 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +target_snapshot: + description: The image's target snapshot. + type: int + sample: 1 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +datastore_id: + description: The image's datastore ID. + type: int + sample: 100 + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +datastore: + description: The image's datastore name. + type: int + sample: image_datastore + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 +vms: + description: The image's list of vm ID's. + type: list + elements: int + returned: when O(state=present), O(state=cloned), or O(state=renamed) + sample: + - 1 + - 2 + - 3 + version_added: 9.5.0 +clones: + description: The image's list of clones ID's. + type: list + elements: int + returned: when O(state=present), O(state=cloned), or O(state=renamed) + sample: + - 1 + - 2 + - 3 + version_added: 9.5.0 +app_clones: + description: The image's list of app_clones ID's. + type: list + elements: int + returned: when O(state=present), O(state=cloned), or O(state=renamed) + sample: + - 1 + - 2 + - 3 + version_added: 9.5.0 +snapshots: + description: The image's list of snapshots. + type: list + returned: when O(state=present), O(state=cloned), or O(state=renamed) + version_added: 9.5.0 + sample: + - date: 123123 + parent: 1 + size: 10228 + allow_orphans: 1 + children: 0 + active: 1 + name: SampleName ''' -try: - import pyone - HAS_PYONE = True -except ImportError: - HAS_PYONE = False - -from ansible.module_utils.basic import AnsibleModule -import os - - -def get_image(module, client, predicate): - # Filter -2 means fetch all images user can Use - pool = client.imagepool.info(-2, -1, -1, -1) - for image in pool.IMAGE: - if predicate(image): - return image +from ansible_collections.community.general.plugins.module_utils.opennebula import OpenNebulaModule - return None - - -def get_image_by_name(module, client, image_name): - return get_image(module, client, lambda image: (image.NAME == image_name)) - - -def get_image_by_id(module, client, image_id): - return get_image(module, client, lambda image: (image.ID == image_id)) +IMAGE_STATES = ['INIT', 'READY', 'USED', 'DISABLED', 'LOCKED', 'ERROR', 'CLONE', 'DELETE', 'USED_PERS', 'LOCKED_USED', 'LOCKED_USED_PERS'] -def get_image_instance(module, client, requested_id, requested_name): - if requested_id: - return get_image_by_id(module, client, requested_id) - else: - return get_image_by_name(module, client, requested_name) +class ImageModule(OpenNebulaModule): + def __init__(self): + argument_spec = dict( + id=dict(type='int', required=False), + name=dict(type='str', required=False), + state=dict(type='str', choices=['present', 'absent', 'cloned', 'renamed'], default='present'), + enabled=dict(type='bool', required=False), + new_name=dict(type='str', required=False), + persistent=dict(type='bool', required=False), + ) + required_if = [ + ['state', 'renamed', ['id']] + ] + mutually_exclusive = [ + ['id', 'name'], + ] + + OpenNebulaModule.__init__(self, + argument_spec, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive, + required_if=required_if) + + def run(self, one, module, result): + params = module.params + id = params.get('id') + name = params.get('name') + desired_state = params.get('state') + enabled = params.get('enabled') + new_name = params.get('new_name') + persistent = params.get('persistent') + + self.result = {} + + image = self.get_image_instance(id, name) + if not image and desired_state != 'absent': + # Using 'if id:' doesn't work properly when id=0 + if id is not None: + module.fail_json(msg="There is no image with id=" + str(id)) + elif name is not None: + module.fail_json(msg="There is no image with name=" + name) + + if desired_state == 'absent': + self.result = self.delete_image(image) + else: + if persistent is not None: + self.result = self.change_persistence(image, persistent) + if enabled is not None: + self.result = self.enable_image(image, enabled) + if desired_state == "cloned": + self.result = self.clone_image(image, new_name) + elif desired_state == "renamed": + self.result = self.rename_image(image, new_name) -IMAGE_STATES = ['INIT', 'READY', 'USED', 'DISABLED', 'LOCKED', 'ERROR', 'CLONE', 'DELETE', 'USED_PERS', 'LOCKED_USED', 'LOCKED_USED_PERS'] + self.exit() + def get_image(self, predicate): + # Filter -2 means fetch all images user can Use + pool = self.one.imagepool.info(-2, -1, -1, -1) -def get_image_info(image): - info = { - 'id': image.ID, - 'name': image.NAME, - 'state': IMAGE_STATES[image.STATE], - 'running_vms': image.RUNNING_VMS, - 'used': bool(image.RUNNING_VMS), - 'user_name': image.UNAME, - 'user_id': image.UID, - 'group_name': image.GNAME, - 'group_id': image.GID, - } + for image in pool.IMAGE: + if predicate(image): + return image - return info + return None + def get_image_by_name(self, image_name): + return self.get_image(lambda image: (image.NAME == image_name)) -def wait_for_state(module, client, image_id, wait_timeout, state_predicate): - import time - start_time = time.time() + def get_image_by_id(self, image_id): + return self.get_image(lambda image: (image.ID == image_id)) - while (time.time() - start_time) < wait_timeout: - image = client.image.info(image_id) - state = image.STATE + def get_image_instance(self, requested_id, requested_name): + # Using 'if requested_id:' doesn't work properly when requested_id=0 + if requested_id is not None: + return self.get_image_by_id(requested_id) + else: + return self.get_image_by_name(requested_name) - if state_predicate(state): - return image + def wait_for_ready(self, image_id, wait_timeout=60): + import time + start_time = time.time() - time.sleep(1) + while (time.time() - start_time) < wait_timeout: + image = self.one.image.info(image_id) + state = image.STATE - module.fail_json(msg="Wait timeout has expired!") + if state in [IMAGE_STATES.index('ERROR')]: + self.module.fail_json(msg="Got an ERROR state: " + image.TEMPLATE['ERROR']) + if state in [IMAGE_STATES.index('READY')]: + return True -def wait_for_ready(module, client, image_id, wait_timeout=60): - return wait_for_state(module, client, image_id, wait_timeout, lambda state: (state in [IMAGE_STATES.index('READY')])) + time.sleep(1) + self.module.fail_json(msg="Wait timeout has expired!") + def wait_for_delete(self, image_id, wait_timeout=60): + import time + start_time = time.time() -def wait_for_delete(module, client, image_id, wait_timeout=60): - return wait_for_state(module, client, image_id, wait_timeout, lambda state: (state in [IMAGE_STATES.index('DELETE')])) + while (time.time() - start_time) < wait_timeout: + # It might be already deleted by the time this function is called + try: + image = self.one.image.info(image_id) + except Exception: + check_image = self.get_image_instance(image_id) + if not check_image: + return True + state = image.STATE -def enable_image(module, client, image, enable): - image = client.image.info(image.ID) - changed = False + if state in [IMAGE_STATES.index('DELETE')]: + return True - state = image.STATE + time.sleep(1) - if state not in [IMAGE_STATES.index('READY'), IMAGE_STATES.index('DISABLED'), IMAGE_STATES.index('ERROR')]: - if enable: - module.fail_json(msg="Cannot enable " + IMAGE_STATES[state] + " image!") - else: - module.fail_json(msg="Cannot disable " + IMAGE_STATES[state] + " image!") + self.module.fail_json(msg="Wait timeout has expired!") - if ((enable and state != IMAGE_STATES.index('READY')) or - (not enable and state != IMAGE_STATES.index('DISABLED'))): - changed = True + def enable_image(self, image, enable): + image = self.one.image.info(image.ID) + changed = False - if changed and not module.check_mode: - client.image.enable(image.ID, enable) + state = image.STATE - result = get_image_info(image) - result['changed'] = changed + if state not in [IMAGE_STATES.index('READY'), IMAGE_STATES.index('DISABLED'), IMAGE_STATES.index('ERROR')]: + if enable: + self.module.fail_json(msg="Cannot enable " + IMAGE_STATES[state] + " image!") + else: + self.module.fail_json(msg="Cannot disable " + IMAGE_STATES[state] + " image!") - return result + if ((enable and state != IMAGE_STATES.index('READY')) or + (not enable and state != IMAGE_STATES.index('DISABLED'))): + changed = True + if changed and not self.module.check_mode: + self.one.image.enable(image.ID, enable) -def clone_image(module, client, image, new_name): - if new_name is None: - new_name = "Copy of " + image.NAME + result = OpenNebulaModule.get_image_info(image) + result['changed'] = changed - tmp_image = get_image_by_name(module, client, new_name) - if tmp_image: - result = get_image_info(tmp_image) - result['changed'] = False return result - if image.STATE == IMAGE_STATES.index('DISABLED'): - module.fail_json(msg="Cannot clone DISABLED image") + def change_persistence(self, image, enable): + image = self.one.image.info(image.ID) + changed = False - if not module.check_mode: - new_id = client.image.clone(image.ID, new_name) - wait_for_ready(module, client, new_id) - image = client.image.info(new_id) + state = image.STATE - result = get_image_info(image) - result['changed'] = True + if state not in [IMAGE_STATES.index('READY'), IMAGE_STATES.index('DISABLED'), IMAGE_STATES.index('ERROR')]: + if enable: + self.module.fail_json(msg="Cannot enable persistence for " + IMAGE_STATES[state] + " image!") + else: + self.module.fail_json(msg="Cannot disable persistence for " + IMAGE_STATES[state] + " image!") - return result + if ((enable and state != IMAGE_STATES.index('READY')) or + (not enable and state != IMAGE_STATES.index('DISABLED'))): + changed = True + if changed and not self.module.check_mode: + self.one.image.persistent(image.ID, enable) -def rename_image(module, client, image, new_name): - if new_name is None: - module.fail_json(msg="'new_name' option has to be specified when the state is 'renamed'") + result = OpenNebulaModule.get_image_info(image) + result['changed'] = changed - if new_name == image.NAME: - result = get_image_info(image) - result['changed'] = False return result - tmp_image = get_image_by_name(module, client, new_name) - if tmp_image: - module.fail_json(msg="Name '" + new_name + "' is already taken by IMAGE with id=" + str(tmp_image.ID)) - - if not module.check_mode: - client.image.rename(image.ID, new_name) - - result = get_image_info(image) - result['changed'] = True - return result + def clone_image(self, image, new_name): + if new_name is None: + new_name = "Copy of " + image.NAME + tmp_image = self.get_image_by_name(new_name) + if tmp_image: + result = OpenNebulaModule.get_image_info(tmp_image) + result['changed'] = False + return result -def delete_image(module, client, image): + if image.STATE == IMAGE_STATES.index('DISABLED'): + self.module.fail_json(msg="Cannot clone DISABLED image") - if not image: - return {'changed': False} + if not self.module.check_mode: + new_id = self.one.image.clone(image.ID, new_name) + self.wait_for_ready(new_id) + image = self.one.image.info(new_id) - if image.RUNNING_VMS > 0: - module.fail_json(msg="Cannot delete image. There are " + str(image.RUNNING_VMS) + " VMs using it.") + result = OpenNebulaModule.get_image_info(image) + result['changed'] = True - if not module.check_mode: - client.image.delete(image.ID) - wait_for_delete(module, client, image.ID) - - return {'changed': True} + return result + def rename_image(self, image, new_name): + if new_name is None: + self.module.fail_json(msg="'new_name' option has to be specified when the state is 'renamed'") -def get_connection_info(module): + if new_name == image.NAME: + result = OpenNebulaModule.get_image_info(image) + result['changed'] = False + return result - url = module.params.get('api_url') - username = module.params.get('api_username') - password = module.params.get('api_password') + tmp_image = self.get_image_by_name(new_name) + if tmp_image: + self.module.fail_json(msg="Name '" + new_name + "' is already taken by IMAGE with id=" + str(tmp_image.ID)) - if not url: - url = os.environ.get('ONE_URL') + if not self.module.check_mode: + self.one.image.rename(image.ID, new_name) - if not username: - username = os.environ.get('ONE_USERNAME') + result = OpenNebulaModule.get_image_info(image) + result['changed'] = True + return result - if not password: - password = os.environ.get('ONE_PASSWORD') + def delete_image(self, image): + if not image: + return {'changed': False} - if not (url and username and password): - module.fail_json(msg="One or more connection parameters (api_url, api_username, api_password) were not specified") - from collections import namedtuple + if image.RUNNING_VMS > 0: + self.module.fail_json(msg="Cannot delete image. There are " + str(image.RUNNING_VMS) + " VMs using it.") - auth_params = namedtuple('auth', ('url', 'username', 'password')) + if not self.module.check_mode: + self.one.image.delete(image.ID) + self.wait_for_delete(image.ID) - return auth_params(url=url, username=username, password=password) + return {'changed': True} def main(): - fields = { - "api_url": {"required": False, "type": "str"}, - "api_username": {"required": False, "type": "str"}, - "api_password": {"required": False, "type": "str", "no_log": True}, - "id": {"required": False, "type": "int"}, - "name": {"required": False, "type": "str"}, - "state": { - "default": "present", - "choices": ['present', 'absent', 'cloned', 'renamed'], - "type": "str" - }, - "enabled": {"required": False, "type": "bool"}, - "new_name": {"required": False, "type": "str"}, - } - - module = AnsibleModule(argument_spec=fields, - mutually_exclusive=[['id', 'name']], - supports_check_mode=True) - - if not HAS_PYONE: - module.fail_json(msg='This module requires pyone to work!') - - auth = get_connection_info(module) - params = module.params - id = params.get('id') - name = params.get('name') - state = params.get('state') - enabled = params.get('enabled') - new_name = params.get('new_name') - client = pyone.OneServer(auth.url, session=auth.username + ':' + auth.password) - - result = {} - - if not id and state == 'renamed': - module.fail_json(msg="Option 'id' is required when the state is 'renamed'") - - image = get_image_instance(module, client, id, name) - if not image and state != 'absent': - if id: - module.fail_json(msg="There is no image with id=" + str(id)) - else: - module.fail_json(msg="There is no image with name=" + name) - - if state == 'absent': - result = delete_image(module, client, image) - else: - result = get_image_info(image) - changed = False - result['changed'] = False - - if enabled is not None: - result = enable_image(module, client, image, enabled) - if state == "cloned": - result = clone_image(module, client, image, new_name) - elif state == "renamed": - result = rename_image(module, client, image, new_name) - - changed = changed or result['changed'] - result['changed'] = changed - - module.exit_json(**result) + ImageModule().run_module() if __name__ == '__main__': diff --git a/plugins/modules/one_image_info.py b/plugins/modules/one_image_info.py index c9d7c4035f7..2ad0f3c4938 100644 --- a/plugins/modules/one_image_info.py +++ b/plugins/modules/one_image_info.py @@ -17,29 +17,14 @@ requirements: - pyone extends_documentation_fragment: + - community.general.opennebula - community.general.attributes - community.general.attributes.info_module options: - api_url: - description: - - URL of the OpenNebula RPC server. - - It is recommended to use HTTPS so that the username/password are not - - transferred over the network unencrypted. - - If not set then the value of the E(ONE_URL) environment variable is used. - type: str - api_username: - description: - - Name of the user to login into the OpenNebula RPC server. If not set - - then the value of the E(ONE_USERNAME) environment variable is used. - type: str - api_password: - description: - - Password of the user to login into OpenNebula RPC server. If not set - - then the value of the E(ONE_PASSWORD) environment variable is used. - type: str ids: description: - A list of images ids whose facts you want to gather. + - Module can use integers too. aliases: ['id'] type: list elements: str @@ -66,9 +51,16 @@ msg: result - name: Gather facts about an image using ID + community.general.one_image_info: + ids: 123 + +- name: Gather facts about an image using list of ID community.general.one_image_info: ids: - 123 + - 456 + - 789 + - 0 - name: Gather facts about an image using the name community.general.one_image_info: @@ -93,182 +85,285 @@ returned: success contains: id: - description: image id + description: The image's id. type: int sample: 153 name: - description: image name + description: The image's name. type: str sample: app1 group_id: - description: image's group id + description: The image's group id type: int sample: 1 group_name: - description: image's group name + description: The image's group name. type: str sample: one-users owner_id: - description: image's owner id + description: The image's owner id. type: int sample: 143 owner_name: - description: image's owner name + description: The image's owner name. type: str sample: ansible-test state: - description: state of image instance + description: The image's state. type: str sample: READY used: - description: is image in use + description: The image's usage status. type: bool sample: true running_vms: - description: count of running vms that use this image + description: The image's count of running vms that use this image. type: int sample: 7 + permissions: + description: The image's permissions. + type: dict + version_added: 9.5.0 + contains: + owner_u: + description: The image's owner USAGE permissions. + type: str + sample: 1 + owner_m: + description: The image's owner MANAGE permissions. + type: str + sample: 0 + owner_a: + description: The image's owner ADMIN permissions. + type: str + sample: 0 + group_u: + description: The image's group USAGE permissions. + type: str + sample: 0 + group_m: + description: The image's group MANAGE permissions. + type: str + sample: 0 + group_a: + description: The image's group ADMIN permissions. + type: str + sample: 0 + other_u: + description: The image's other users USAGE permissions. + type: str + sample: 0 + other_m: + description: The image's other users MANAGE permissions. + type: str + sample: 0 + other_a: + description: The image's other users ADMIN permissions + type: str + sample: 0 + sample: + owner_u: 1 + owner_m: 0 + owner_a: 0 + group_u: 0 + group_m: 0 + group_a: 0 + other_u: 0 + other_m: 0 + other_a: 0 + type: + description: The image's type. + type: int + sample: 0 + version_added: 9.5.0 + disk_type: + description: The image's format type. + type: int + sample: 0 + version_added: 9.5.0 + persistent: + description: The image's persistence status (1 means true, 0 means false). + type: int + sample: 1 + version_added: 9.5.0 + source: + description: The image's source. + type: str + sample: /var/lib/one//datastores/100/somerandomstringxd + version_added: 9.5.0 + path: + description: The image's filesystem path. + type: str + sample: /var/tmp/hello.qcow2 + version_added: 9.5.0 + fstype: + description: The image's filesystem type. + type: str + sample: ext4 + version_added: 9.5.0 + size: + description: The image's size in MegaBytes. + type: int + sample: 10000 + version_added: 9.5.0 + cloning_ops: + description: The image's cloning operations per second. + type: int + sample: 0 + version_added: 9.5.0 + cloning_id: + description: The image's cloning ID. + type: int + sample: -1 + version_added: 9.5.0 + target_snapshot: + description: The image's target snapshot. + type: int + sample: 1 + version_added: 9.5.0 + datastore_id: + description: The image's datastore ID. + type: int + sample: 100 + version_added: 9.5.0 + datastore: + description: The image's datastore name. + type: int + sample: image_datastore + version_added: 9.5.0 + vms: + description: The image's list of vm ID's. + type: list + elements: int + version_added: 9.5.0 + sample: + - 1 + - 2 + - 3 + clones: + description: The image's list of clones ID's. + type: list + elements: int + version_added: 9.5.0 + sample: + - 1 + - 2 + - 3 + app_clones: + description: The image's list of app_clones ID's. + type: list + elements: int + version_added: 9.5.0 + sample: + - 1 + - 2 + - 3 + snapshots: + description: The image's list of snapshots. + type: list + version_added: 9.5.0 + sample: + - date: 123123 + parent: 1 + size: 10228 + allow_orphans: 1 + children: 0 + active: 1 + name: SampleName ''' -try: - import pyone - HAS_PYONE = True -except ImportError: - HAS_PYONE = False - -from ansible.module_utils.basic import AnsibleModule -import os - -def get_all_images(client): - pool = client.imagepool.info(-2, -1, -1, -1) - # Filter -2 means fetch all images user can Use - - return pool +from ansible_collections.community.general.plugins.module_utils.opennebula import OpenNebulaModule IMAGE_STATES = ['INIT', 'READY', 'USED', 'DISABLED', 'LOCKED', 'ERROR', 'CLONE', 'DELETE', 'USED_PERS', 'LOCKED_USED', 'LOCKED_USED_PERS'] -def get_image_info(image): - info = { - 'id': image.ID, - 'name': image.NAME, - 'state': IMAGE_STATES[image.STATE], - 'running_vms': image.RUNNING_VMS, - 'used': bool(image.RUNNING_VMS), - 'user_name': image.UNAME, - 'user_id': image.UID, - 'group_name': image.GNAME, - 'group_id': image.GID, - } - return info - - -def get_images_by_ids(module, client, ids): - images = [] - pool = get_all_images(client) - - for image in pool.IMAGE: - if str(image.ID) in ids: - images.append(image) - ids.remove(str(image.ID)) - if len(ids) == 0: - break - - if len(ids) > 0: - module.fail_json(msg='There is no IMAGE(s) with id(s)=' + ', '.join('{id}'.format(id=str(image_id)) for image_id in ids)) - - return images +class ImageInfoModule(OpenNebulaModule): + def __init__(self): + argument_spec = dict( + ids=dict(type='list', aliases=['id'], elements='str', required=False), + name=dict(type='str', required=False), + ) + mutually_exclusive = [ + ['ids', 'name'], + ] + + OpenNebulaModule.__init__(self, + argument_spec, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive) + + def run(self, one, module, result): + params = module.params + ids = params.get('ids') + name = params.get('name') + + if ids: + images = self.get_images_by_ids(ids) + elif name: + images = self.get_images_by_name(name) + else: + images = self.get_all_images().IMAGE + self.result = { + 'images': [OpenNebulaModule.get_image_info(image) for image in images] + } -def get_images_by_name(module, client, name_pattern): + self.exit() - images = [] - pattern = None + def get_all_images(self): + pool = self.one.imagepool.info(-2, -1, -1, -1) + # Filter -2 means fetch all images user can Use - pool = get_all_images(client) + return pool - if name_pattern.startswith('~'): - import re - if name_pattern[1] == '*': - pattern = re.compile(name_pattern[2:], re.IGNORECASE) - else: - pattern = re.compile(name_pattern[1:]) + def get_images_by_ids(self, ids): + images = [] + pool = self.get_all_images() - for image in pool.IMAGE: - if pattern is not None: - if pattern.match(image.NAME): + for image in pool.IMAGE: + if str(image.ID) in ids: images.append(image) - elif name_pattern == image.NAME: - images.append(image) - break - - # if the specific name is indicated - if pattern is None and len(images) == 0: - module.fail_json(msg="There is no IMAGE with name=" + name_pattern) + ids.remove(str(image.ID)) + if len(ids) == 0: + break - return images + if len(ids) > 0: + self.module.fail_json(msg='There is no IMAGE(s) with id(s)=' + ', '.join('{id}'.format(id=str(image_id)) for image_id in ids)) + return images -def get_connection_info(module): + def get_images_by_name(self, name_pattern): + images = [] + pattern = None - url = module.params.get('api_url') - username = module.params.get('api_username') - password = module.params.get('api_password') + pool = self.get_all_images() - if not url: - url = os.environ.get('ONE_URL') + if name_pattern.startswith('~'): + import re + if name_pattern[1] == '*': + pattern = re.compile(name_pattern[2:], re.IGNORECASE) + else: + pattern = re.compile(name_pattern[1:]) - if not username: - username = os.environ.get('ONE_USERNAME') - - if not password: - password = os.environ.get('ONE_PASSWORD') - - if not (url and username and password): - module.fail_json(msg="One or more connection parameters (api_url, api_username, api_password) were not specified") - from collections import namedtuple + for image in pool.IMAGE: + if pattern is not None: + if pattern.match(image.NAME): + images.append(image) + elif name_pattern == image.NAME: + images.append(image) + break - auth_params = namedtuple('auth', ('url', 'username', 'password')) + # if the specific name is indicated + if pattern is None and len(images) == 0: + self.module.fail_json(msg="There is no IMAGE with name=" + name_pattern) - return auth_params(url=url, username=username, password=password) + return images def main(): - fields = { - "api_url": {"required": False, "type": "str"}, - "api_username": {"required": False, "type": "str"}, - "api_password": {"required": False, "type": "str", "no_log": True}, - "ids": {"required": False, "aliases": ['id'], "type": "list", "elements": "str"}, - "name": {"required": False, "type": "str"}, - } - - module = AnsibleModule(argument_spec=fields, - mutually_exclusive=[['ids', 'name']], - supports_check_mode=True) - - if not HAS_PYONE: - module.fail_json(msg='This module requires pyone to work!') - - auth = get_connection_info(module) - params = module.params - ids = params.get('ids') - name = params.get('name') - client = pyone.OneServer(auth.url, session=auth.username + ':' + auth.password) - - if ids: - images = get_images_by_ids(module, client, ids) - elif name: - images = get_images_by_name(module, client, name) - else: - images = get_all_images(client).IMAGE - - result = { - 'images': [get_image_info(image) for image in images], - } - - module.exit_json(**result) + ImageInfoModule().run_module() if __name__ == '__main__': diff --git a/tests/integration/targets/one_image/aliases b/tests/integration/targets/one_image/aliases new file mode 100644 index 00000000000..100ba0f979b --- /dev/null +++ b/tests/integration/targets/one_image/aliases @@ -0,0 +1,7 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/generic/1 +cloud/opennebula +disabled # FIXME - when this is fixed, also re-enable the generic tests in CI! diff --git a/tests/integration/targets/one_image/tasks/main.yml b/tests/integration/targets/one_image/tasks/main.yml new file mode 100644 index 00000000000..c8736d73d8f --- /dev/null +++ b/tests/integration/targets/one_image/tasks/main.yml @@ -0,0 +1,210 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Checks for existence +- name: Make sure image is present by ID + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: present + register: result + +- name: Assert that image is present + assert: + that: + - result is not changed + +- name: Make sure image is present by ID + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: my_image + state: present + register: result + +- name: Assert that image is present + assert: + that: + - result is not changed + +# Updating an image +- name: Clone image without name + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: cloned + register: result + +- name: Assert that image is cloned + assert: + that: + - result is changed + +- name: Clone image with name + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: renamed + new_name: new_image + register: result + +- name: Assert that image is cloned + assert: + that: + - result is changed + +- name: Disable image + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + enabled: false + register: result + +- name: Assert that network is disabled + assert: + that: + - result is changed + +- name: Enable image + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + enabled: true + register: result + +- name: Assert that network is enabled + assert: + that: + - result is changed + +- name: Make image persistent + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + persistent: true + register: result + +- name: Assert that network is persistent + assert: + that: + - result is changed + +- name: Make image non-persistent + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + persistent: false + register: result + +- name: Assert that network is non-persistent + assert: + that: + - result is changed + +# Testing idempotence using the same tasks +- name: Make image non-persistent + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + persistent: false + enabled: true + register: result + +- name: Assert that network not changed + assert: + that: + - result is not changed + +# Delete images +- name: Deleting non-existing image + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 228 + state: absent + register: result + +- name: Assert that network not changed + assert: + that: + - result is not changed + +- name: Delete an existing image + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: absent + register: result + +- name: Assert that image was deleted + assert: + that: + - result is changed + +# Trying to run with wrong arguments +- name: Try to use name and ID at the same time + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + name: name + register: result + ignore_errors: true + +- name: Assert that task failed + assert: + that: + - result is failed + +- name: Try to rename image without specifying new name + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: rename + register: result + ignore_errors: true + +- name: Assert that task failed + assert: + that: + - result is failed + +- name: Try to rename image without specifying new name + one_image: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: rename + register: result + ignore_errors: true diff --git a/tests/integration/targets/one_image_info/aliases b/tests/integration/targets/one_image_info/aliases new file mode 100644 index 00000000000..100ba0f979b --- /dev/null +++ b/tests/integration/targets/one_image_info/aliases @@ -0,0 +1,7 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/generic/1 +cloud/opennebula +disabled # FIXME - when this is fixed, also re-enable the generic tests in CI! diff --git a/tests/integration/targets/one_image_info/tasks/main.yml b/tests/integration/targets/one_image_info/tasks/main.yml new file mode 100644 index 00000000000..fede1162417 --- /dev/null +++ b/tests/integration/targets/one_image_info/tasks/main.yml @@ -0,0 +1,192 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Checks for existence +- name: Get info by ID + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + register: result + +- name: Assert that image is present + assert: + that: + - result is not changed + +- name: Get info by list of ID + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + ids: + - 2 + - 2 + - 8 + register: result + +- name: Assert that image is present + assert: + that: + - result is not changed + +- name: Get info by list of ID + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: somename + register: result + +- name: Assert that image is present + assert: + that: + - result is not changed + +- name: Gather all info + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + register: result + +- name: Assert that images are present + assert: + that: + - result is not changed + +- name: Gather info by regex + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: '~my_image-[0-9].*' + register: result + +- name: Assert that images are present + assert: + that: + - result is not changed + +- name: Gather info by regex and ignore upper/lower cases + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: '~*my_image-[0-9].*' + register: result + +- name: Assert that images are present + assert: + that: + - result is not changed + +# Updating an image +- name: Clone image without name + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: cloned + register: result + +- name: Assert that image is cloned + assert: + that: + - result is changed + +- name: Clone image with name + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: renamed + new_name: new_image + register: result + +- name: Assert that image is cloned + assert: + that: + - result is changed + +- name: Disable image + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + enabled: false + register: result + +- name: Assert that network is disabled + assert: + that: + - result is changed + +- name: Enable image + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + enabled: true + register: result + +- name: Assert that network is enabled + assert: + that: + - result is changed + +- name: Make image persistent + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + persistent: true + register: result + +- name: Assert that network is persistent + assert: + that: + - result is changed + +- name: Make image non-persistent + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + persistent: false + register: result + +- name: Assert that network is non-persistent + assert: + that: + - result is changed + +# Testing errors +- name: Try to use name and ID a the same time + one_image_info: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + name: somename + register: result + ignore_errors: true + +- name: Assert that network not changed + assert: + that: + - result is failed