From a73e2a924bde5649c310320bf54f327c672e720e Mon Sep 17 00:00:00 2001 From: Milan Ilic <35260522+ilicmilan@users.noreply.github.com> Date: Mon, 2 Apr 2018 16:58:39 +0200 Subject: [PATCH] Add OpenNebula one_image module (#37831) --- .../modules/cloud/opennebula/one_image.py | 424 ++++++++++++++++++ test/legacy/opennebula.yml | 1 + test/legacy/roles/one_image/defaults/main.yml | 9 + test/legacy/roles/one_image/tasks/main.yml | 287 ++++++++++++ 4 files changed, 721 insertions(+) create mode 100644 lib/ansible/modules/cloud/opennebula/one_image.py create mode 100644 test/legacy/roles/one_image/defaults/main.yml create mode 100644 test/legacy/roles/one_image/tasks/main.yml diff --git a/lib/ansible/modules/cloud/opennebula/one_image.py b/lib/ansible/modules/cloud/opennebula/one_image.py new file mode 100644 index 0000000000..825d8ea43c --- /dev/null +++ b/lib/ansible/modules/cloud/opennebula/one_image.py @@ -0,0 +1,424 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +""" +(c) 2018, Milan Ilic + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a clone of the GNU General Public License +along with Ansible. If not, see . +""" + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1'} + +DOCUMENTATION = ''' +--- +module: one_image +short_description: Manages OpenNebula images +description: + - Manages OpenNebula images +version_added: "2.6" +requirements: + - python-oca +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 C(ONE_URL) environment variable is used. + api_username: + description: + - Name of the user to login into the OpenNebula RPC server. If not set + - then the value of the C(ONE_USERNAME) environment variable is used. + api_password: + description: + - Password of the user to login into OpenNebula RPC server. If not set + - then the value of the C(ONE_PASSWORD) environment variable is used. + id: + description: + - A C(id) of the image you would like to manage. + name: + description: + - A C(name) of the image you would like to manage. + state: + description: + - C(present) - state that is used to manage the image + - C(absent) - delete the image + - C(cloned) - clone the image + - C(renamed) - rename the image to the C(new_name) + choices: ["present", "absent", "cloned", "renamed"] + default: present + enabled: + description: + - Whether the image should be enabled or disabled. + type: bool + new_name: + description: + - A name that will be assigned to the existing or new image. + - In the case of cloning, by default C(new_name) will take the name of the origin image with the prefix 'Copy of'. +author: + - "Milan Ilic (@ilicmilan)" +''' + +EXAMPLES = ''' +# Fetch the IMAGE by id +- one_image: + id: 45 + register: result + +# Print the IMAGE properties +- debug: + msg: result + +# Rename existing IMAGE +- one_image: + id: 34 + state: renamed + new_name: bar-image + +# Disable the IMAGE by id +- one_image: + id: 37 + enabled: no + +# Enable the IMAGE by name +- one_image: + name: bar-image + enabled: yes + +# Clone the IMAGE by name +- one_image: + name: bar-image + state: cloned + new_name: bar-image-clone + register: result + +# Delete the IMAGE by id +- one_image: + id: '{{ result.id }}' + state: absent +''' + +RETURN = ''' +id: + description: image id + type: int + returned: success + sample: 153 +name: + description: image name + type: string + returned: success + sample: app1 +group_id: + description: image's group id + type: int + returned: success + sample: 1 +group_name: + description: image's group name + type: string + returned: success + sample: one-users +owner_id: + description: image's owner id + type: int + returned: success + sample: 143 +owner_name: + description: image's owner name + type: string + returned: success + sample: ansible-test +state: + description: state of image instance + type: string + returned: success + sample: READY +used: + description: is image in use + type: bool + returned: success + sample: true +running_vms: + description: count of running vms that use this image + type: int + returned: success + sample: 7 +''' + +try: + import oca + HAS_OCA = True +except ImportError: + HAS_OCA = False + +from ansible.module_utils.basic import AnsibleModule +import os + + +def get_image(module, client, predicate): + pool = oca.ImagePool(client) + # Filter -2 means fetch all images user can Use + pool.info(filter=-2) + + for image in pool: + if predicate(image): + return image + + 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)) + + +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) + + +IMAGE_STATES = ['INIT', 'READY', 'USED', 'DISABLED', 'LOCKED', 'ERROR', 'CLONE', 'DELETE', 'USED_PERS', 'LOCKED_USED', 'LOCKED_USED_PERS'] + + +def get_image_info(image): + image.info() + + 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 wait_for_state(module, image, wait_timeout, state_predicate): + import time + start_time = time.time() + + while (time.time() - start_time) < wait_timeout: + image.info() + state = image.state + + if state_predicate(state): + return image + + time.sleep(1) + + module.fail_json(msg="Wait timeout has expired!") + + +def wait_for_ready(module, image, wait_timeout=60): + return wait_for_state(module, image, wait_timeout, lambda state: (state in [IMAGE_STATES.index('READY')])) + + +def wait_for_delete(module, image, wait_timeout=60): + return wait_for_state(module, image, wait_timeout, lambda state: (state in [IMAGE_STATES.index('DELETE')])) + + +def enable_image(module, client, image, enable): + image.info() + changed = False + + state = image.state + + 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!") + + if ((enable and state != IMAGE_STATES.index('READY')) or + (not enable and state != IMAGE_STATES.index('DISABLED'))): + changed = True + + if changed and not module.check_mode: + client.call('image.enable', image.id, enable) + + result = get_image_info(image) + result['changed'] = changed + + return result + + +def clone_image(module, client, image, new_name): + if new_name is None: + new_name = "Copy of " + image.name + + 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") + + if not module.check_mode: + new_id = client.call('image.clone', image.id, new_name) + image = get_image_by_id(module, client, new_id) + wait_for_ready(module, image) + + result = get_image_info(image) + result['changed'] = True + + return result + + +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'") + + 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.call('image.rename', image.id, new_name) + + result = get_image_info(image) + result['changed'] = True + return result + + +def delete_image(module, client, image): + + if not image: + return {'changed': False} + + if image.running_vms > 0: + module.fail_json(msg="Cannot delete image. There are " + str(image.running_vms) + " VMs using it.") + + if not module.check_mode: + client.call('image.delete', image.id) + wait_for_delete(module, image) + + return {'changed': True} + + +def get_connection_info(module): + + url = module.params.get('api_url') + username = module.params.get('api_username') + password = module.params.get('api_password') + + if not url: + url = os.environ.get('ONE_URL') + + 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 + + auth_params = namedtuple('auth', ('url', 'username', 'password')) + + return auth_params(url=url, username=username, password=password) + + +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_OCA: + module.fail_json(msg='This module requires python-oca 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 = oca.Client(auth.username + ':' + auth.password, auth.url) + + 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) + + +if __name__ == '__main__': + main() diff --git a/test/legacy/opennebula.yml b/test/legacy/opennebula.yml index 43d81808d8..fcbf907e3d 100644 --- a/test/legacy/opennebula.yml +++ b/test/legacy/opennebula.yml @@ -2,3 +2,4 @@ - hosts: localhost roles: - { role: one_vm, tags: test_one_vm } + - { role: one_image, tags: test_one_image } diff --git a/test/legacy/roles/one_image/defaults/main.yml b/test/legacy/roles/one_image/defaults/main.yml new file mode 100644 index 0000000000..662eefa7d1 --- /dev/null +++ b/test/legacy/roles/one_image/defaults/main.yml @@ -0,0 +1,9 @@ +--- +# This is a role for running integration test of the one_image module. +# For this role to be used you need to meet the following prerequisites: +# 1. Environment variables ONE_URL, ONE_USERNAME and ONE_PASSWORD +# need to be set. +# 2. Image needs to exist. +# 3. Play vars need to be set bellow to reflect the image IDs, image names, etc. + +one_image_name: 'one_image_test' diff --git a/test/legacy/roles/one_image/tasks/main.yml b/test/legacy/roles/one_image/tasks/main.yml new file mode 100644 index 0000000000..b0738d3c3e --- /dev/null +++ b/test/legacy/roles/one_image/tasks/main.yml @@ -0,0 +1,287 @@ +--- +- name: Check that '{{ one_image_name }}' exists + one_image: + name: '{{ one_image_name }}' + +- name: Try to fetch non-existent image by name + one_image: + name: non-existent-vm-{{ ansible_date_time.iso8601_basic_short }} + register: image_missing + failed_when: not image_missing is failed + +- name: Try to fetch non-existent image by id + one_image: + id: -999 + register: image_missing + failed_when: not image_missing is failed + +- name: Try to fetch image by id and name + one_image: + id: 35 + name: '{{ one_image_name }}' + register: module_failed + failed_when: not module_failed is failed + +- name: Fetch image info + one_image: + name: '{{ one_image_name }}' + register: unused_image + +- name: Check is the image in USE + assert: + that: + - not unused_image is changed + - unused_image.name == one_image_name + - unused_image.running_vms == 0 + - unused_image.state == "READY" + - not unused_image.used|bool + msg: 'Image is USED' + +- name: Enable image + one_image: + id: '{{ unused_image.id }}' + enabled: yes + +- name: Disable the image in check-mode + one_image: + name: '{{ one_image_name }}' + enabled: no + check_mode: yes + register: disable_image + +- name: Check if task in check-mode returns as 'changed' + assert: + that: disable_image is changed + msg: 'Disabling the enabled image in check-mode should return as changed.' + +- name: Disable the image again in check-mode to check idempotence + one_image: + name: '{{ one_image_name }}' + enabled: no + check_mode: yes + register: disable_image2 + +- name: Check if task in check-mode returns as 'changed' + assert: + that: disable_image2 is changed + msg: 'Disabling the enabled image in check-mode should return as changed.' + +- name: Disable the image + one_image: + name: '{{ one_image_name }}' + enabled: no + register: disable_image + +- name: Check if image's state is 'DISABLED' + assert: + that: + - disable_image is changed + - disable_image.state == "DISABLED" + msg: 'Disabling the enabled image was unsuccessful.' + +- block: + - name: Try to clone disabled image + one_image: + name: '{{ one_image_name }}' + state: cloned + new_name: '{{ one_image_name }}-clone' + register: clone_image + failed_when: not clone_image is failed + rescue: + - name: Delete new image + one_image: + name: '{{ one_image_name }}-clone' + state: absent + +- name: Enable the image + one_image: + name: '{{ one_image_name }}' + enabled: yes + +- block: + - name: Check that clone image doesn't exist + one_image: + name: '{{ one_image_name }}-clone' + register: clone_image_result + failed_when: not clone_image_result is failed + + - name: Clone the image in check-mode + one_image: + name: '{{ one_image_name }}' + state: cloned + new_name: '{{ one_image_name }}-clone' + register: new_image + check_mode: yes + + - name: Check if cloning in check-mode was returned as 'changed' + assert: + that: new_image is changed + msg: "Cloning image in check-mode should be returned as 'changed'" + + - name: Check that new image doesn't exist + one_image: + name: '{{ one_image_name }}-clone' + register: new_image_result + failed_when: not new_image_result is failed + + - name: Clone the image + one_image: + name: '{{ one_image_name }}' + state: cloned + new_name: '{{ one_image_name }}-clone' + register: new_image + + - name: Verify cloning of the image + assert: + that: + - new_image is changed + - new_image.name == '{{ one_image_name }}-clone' + - new_image.state == "READY" + - not new_image.used|bool + + - name: Clone the image again to check idempotence + one_image: + name: '{{ one_image_name }}' + state: cloned + new_name: '{{ one_image_name }}-clone' + register: new_image + + - name: Verify cloning of the image + assert: + that: + - not new_image is changed + - new_image.name == '{{ one_image_name }}-clone' + - new_image.state == "READY" + - not new_image.used|bool + + - name: Try to rename an image without a passed new name + one_image: + id: '{{ new_image.id }}' + state: renamed + register: rename_fail + failed_when: not rename_fail is failed + + - name: Verify a fail message + assert: + that: + - rename_fail.msg == "'new_name' option has to be specified when the state is 'renamed'" + + - name: Set the image's new name + set_fact: + image_new_name: test-{{ ansible_date_time.iso8601_basic_short }} + + - name: Try to rename an image without specified id + one_image: + name: '{{ new_image.name }}' + state: renamed + new_name: '{{ image_new_name }}' + register: rename_fail + failed_when: not rename_fail is failed + + - name: Verify a fail message + assert: + that: + - rename_fail.msg == "Option 'id' is required when the state is 'renamed'" + + - name: Rename cloned instance in check-mode + one_image: + id: '{{ new_image.id }}' + state: renamed + new_name: '{{ image_new_name }}' + register: new_name_check + check_mode: yes + + - name: Check if previous task is returned as 'changed' + assert: + that: new_name_check is changed + msg: "Renaming in check-mode should return as 'changed'." + + - name: Check if that image wasn't renamed in check-mode + assert: + that: new_name_check.name == new_image.name + msg: "Renaming in check-mode shouldn't rename the image." + + - name: Rename cloned instance + one_image: + id: '{{ new_image.id }}' + state: renamed + new_name: '{{ image_new_name }}' + register: new_name + + - name: Check that name is correctly assigned + assert: + that: + - new_name is changed + - new_name.name == image_new_name + - new_name.id == new_image.id + msg: "The new name wasn't assigned correctly" + + - name: Rename cloned instance again to check idempotence + one_image: + id: '{{ new_name.id }}' + state: renamed + new_name: '{{ image_new_name }}' + register: new_name + + - name: Check if renaming is idempotent + assert: + that: not new_name is changed + msg: "Renaming should be idempotent." + + - name: Try to assigned name of the existent image + one_image: + id: '{{ new_name.id }}' + state: renamed + new_name: '{{ one_image_name }}' + register: existent_name + failed_when: not existent_name is failed + + - name: Verify the fail message + assert: + that: + - existent_name.msg is match("Name '{{ one_image_name }}' is already taken by IMAGE with id=\d+") + + - name: Delete new image in check-mode + one_image: + name: '{{ image_new_name }}' + state: absent + register: delete_new_image_check + check_mode: yes + + - name: Check if deletion in check-mode was returned as 'changed' + assert: + that: delete_new_image_check is changed + msg: "Deletion of the image in check-mode should return as 'changed'." + + - name: Delete new image + one_image: + name: '{{ image_new_name }}' + state: absent + register: delete_new_image + + - name: Check if deletion was returned as 'changed' + assert: + that: delete_new_image is changed + msg: "Deletion of the existent image should return as 'changed'." + + - name: Delete the image again to check idempotece + one_image: + name: '{{ image_new_name }}' + state: absent + register: delete_new_image + + - name: Check if deletion was returned as 'changed' + assert: + that: not delete_new_image is changed + msg: "Deletion of the non-existent image shouldn't return as 'changed'." + + always: + - name: Delete image + one_image: + name: '{{ one_image_name }}-clone' + state: absent + + - name: Delete image + one_image: + name: '{{ image_new_name }}' + state: absent