diff --git a/library/decort_disk.py b/library/decort_disk.py new file mode 100644 index 0000000..e13cff3 --- /dev/null +++ b/library/decort_disk.py @@ -0,0 +1,531 @@ +#!/usr/bin/python +# +# Digital Enegry Cloud Orchestration Technology (DECORT) modules for Ansible +# Copyright: (c) 2018-2020 Digital Energy Cloud Solutions LLC +# +# Apache License 2.0 (see http://www.apache.org/licenses/LICENSE-2.0.txt) +# + +# +# Author: Sergey Shubin (sergey.shubin@digitalenergy.online) +# + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: decort_disk +short_description: Manage Disks (virtualized storage resources) in DECORT cloud +description: > + This module can be used to create new disk in DECORT cloud platform, obtain or + modify its characteristics, and delete it. +version_added: "2.2" +author: + - Sergey Shubin +requirements: + - python >= 2.6 + - PyJWT module + - requests module + - decort_utils utility library (module) + - DECORT cloud platform version 3.4.1 or higher +notes: + - Environment variables can be used to pass selected parameters to the module, see details below. + - Specified Oauth2 provider must be trusted by the DECORT cloud controller on which JWT will be used. + - 'Similarly, JWT supplied in I(authenticator=jwt) mode should be received from Oauth2 provider trusted by + the DECORT cloud controller on which this JWT will be used.' +options: + account_id: + description: + - ID of the account, which owns this disk. This is the alternative to I(account_name) option. + - If both I(account_id) and I(account_name) specified, then I(account_name) is ignored. + default: 0 + required: no + account_name: + description: + - 'Name of the account, which will own this disk.' + - 'This parameter is ignored if I(account_id) is specified.' + default: empty string + required: no + annotation: + description: + - Optional text description of this disk. + default: empty string + required: no + app_id: + description: + - 'Application ID for authenticating to the DECORT controller when I(authenticator=oauth2).' + - 'Required if I(authenticator=oauth2).' + - 'If not found in the playbook or command line arguments, the value will be taken from DECORT_APP_ID + environment variable.' + required: no + app_secret: + description: + - 'Application API secret used for authenticating to the DECORT controller when I(authenticator=oauth2).' + - This parameter is required when I(authenticator=oauth2) and ignored in other modes. + - 'If not found in the playbook or command line arguments, the value will be taken from DECORT_APP_SECRET + environment variable.' + required: no + authenticator: + description: + - Authentication mechanism to be used when accessing DECORT controller and authorizing API call. + default: jwt + choices: [ jwt, oauth2, legacy ] + required: yes + controller_url: + description: + - URL of the DECORT controller that will be contacted to manage the RG according to the specification. + - 'This parameter is always required regardless of the specified I(authenticator) type.' + required: yes + disk_id: + description: + - `ID of the disk to manage. If I(disk_id) is specified it is assumed, that this disk already + exists. In other words, you cannot create new disk by specifying its ID, use I(disk_name) + when creating new disk.` + - `If non-zero I(disk_id) is specified, then I(disk_name), I(account_id) and I(account_name) + are ignored.` + default: 0 + required: no + disk_name: + description: + - `Name of the disk to manage. To manage disk by name you also need to specify either + I(account_id) or I(account_name).` + - If non-zero I(disk_id) is specified, I(disk_name) is ignored. + - `Note that the platform does not enforce uniqueness of disk names, so if more than one + disk with this name exists under the specified account, module will return the first + occurence.` + default: empty string + required: no + force_detach: + description: + - `By default it is not allowed to delete or destroy disk that is currently attached to a compute + instance (e.g. virtual machine or bare metal server). Set this argument to true to change this + behavior.` + - This argument is meaningful for I(state=absent) operations only and ignored otherwise. + default: false + required: no + jwt: + description: + - 'JWT (access token) for authenticating to the DECORT controller when I(authenticator=jwt).' + - 'This parameter is required if I(authenticator=jwt) and ignored for other authentication modes.' + - If not specified in the playbook, the value will be taken from DECORT_JWT environment variable. + required: no + oauth2_url: + description: + - 'URL of the oauth2 authentication provider to use when I(authenticator=oauth2).' + - 'This parameter is required when when I(authenticator=oauth2).' + - 'If not specified in the playbook, the value will be taken from DECORT_OAUTH2_URL environment variable.' + password: + description: + - 'Password for authenticating to the DECORT controller when I(authenticator=legacy).' + - 'This parameter is required if I(authenticator=legacy) and ignored in other authentication modes.' + - If not specified in the playbook, the value will be taken from DECORT_PASSWORD environment variable. + required: no + place_with: + description: + - `This argument can be used to simplify data disks creation along with a new compute, by placing + disks in the same storage, where corresponding OS image is deployed.` + - `Specify ID of an OS image, and the newly created disk will be provisioned from the same + storage, where this OS image is located. You may optionally specify I(pool) to control + actual disk placement within that storage, or leave I(pool=default) to let platform manage + it automatically.` + - This parameter is used when creating new disks and ignored for all other operations. + - This is an alternative to specifying I(sep_id). + default: 0 + required: no + pool: + description: + - Name of the pool where to place new disk. Once disk is created, its pool cannot be changed. + - This parameter is used when creating new disk and igonred for all other operations. + default: default + required: no + sep_id: + description: + - `ID of the Storage Endpoint Provider (SEP) where to place new disk. Once disk is created, + its SEP cannot be changed.` + - `You may think of SEP as an identifier of a storage system connected to DECORT platform. There + may be several different storage systems and, consequently, several SEPs available to choose from.` + - This parameter is used when creating new disk and igonred for all other operations. + - See also I(place_with) for an alternative way to specify disk placement. + default: 0 + required: no + size: + description: + - Size of the disk in GB. This parameter is mandatory when creating new disk. + - `If specified for an existing disk, and it is greater than current disk size, platform will try to resize + the disk on the fly. Downsizing disk is not allowed.` + required: no + state: + description: + - Specify the desired state of the disk at the exit of the module. + - 'If desired I(state=present):' + - ' - Disk does not exist or is in [DESTROYED, PURGED] states, create new disk according to the specifications.' + - ' - Disk is in DELETED state, restore it and change size if necessary.' + - ' - Disk is in one of [CREATED, ASSIGNED] states, do nothing.' + - ' - Disk in any other state, abort with an error.' + - 'If desired I(state=absent):' + - ' - Disk is in one of [CREATED, ASSIGNED, DELETED] states, destroy it.' + - ' - Disk not found or in [DESTROYED, PURGED] states, do nothing.' + - ' - Disk in any other state, abort with an error.' + default: present + choices: [ absent, present ] + user: + description: + - 'Name of the legacy user for authenticating to the DECORT controller when I(authenticator=legacy).' + - 'This parameter is required when I(authenticator=legacy) and ignored for other authentication modes.' + - If not specified in the playbook, the value will be taken from DECORT_USER environment variable. + required: no + verify_ssl: + description: + - 'Controls SSL verification mode when making API calls to DECORT controller. Set it to False if you + want to disable SSL certificate verification. Intended use case is when you run module in a trusted + environment that uses self-signed certificates. Note that disabling SSL verification in any other + scenario can lead to security issues, so please know what you are doing.' + default: True + required: no + workflow_callback: + description: + - 'Callback URL that represents an application, which invokes this module (e.g. up-level orchestrator or + end-user portal) and may except out-of-band updates on progress / exit status of the module run.' + - API call at this URL will be used to relay such information to the application. + - 'API call payload will include module-specific details about this module run and I(workflow_context).' + required: no + workflow_context: + description: + - 'Context data that will be included into the payload of the API call directed at I(workflow_callback) URL.' + - 'This context data is expected to uniquely identify the task carried out by this module invocation so + that up-level orchestrator could match returned information to the its internal entities.' + required: no +''' + +EXAMPLES = ''' +- name: create new Disk named "MyDataDisk01" of size 50 GB, on SEP ID 1, in default pool, under the account "MyAccount". + decort_vins: + authenticator: oauth2 + app_id: "{{ MY_APP_ID }}" + app_secret: "{{ MY_APP_SECRET }}" + controller_url: "https://cloud.digitalenergy.online" + disk_name: "MyDataDisk01" + sep_id: 1 + pool: "default" + size: 50 + account_name: "MyAccount" + state: present + delegate_to: localhost + register: my_disk +''' + +RETURN = ''' +facts: + description: facts about the disk + returned: always + type: dict + sample: + facts: + id: 50 + name: data01 + size: 10 + sep_id: 1 + pool: datastore + state: ASSIGNED + account_id: 7 + attached_to: 18 + gid: 1001 +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback + +from ansible.module_utils.decort_utils import * + + +def decort_disk_package_facts(disk_facts, check_mode=False): + """Package a dictionary of disk facts according to the decort_vins module specification. + This dictionary will be returned to the upstream Ansible engine at the completion of + the module run. + + @param (dict) disk_facts: dictionary with Disk facts as returned by API call to .../disks/get + @param (bool) check_mode: boolean that tells if this Ansible module is run in check mode + """ + + ret_dict = dict(id=0, + name="none", + state="CHECK_MODE", + size=0, + account_id=0, + sep_id=0, + pool="none", + attached_to=0, + gid=0 + ) + + if check_mode: + # in check mode return immediately with the default values + return ret_dict + + if disk_facts is None: + # if void facts provided - change state value to ABSENT and return + ret_dict['state'] = "ABSENT" + return ret_dict + + ret_dict['id'] = disk_facts['id'] + ret_dict['name'] = disk_facts['name'] + ret_dict['size'] = disk_facts['sizeMax'] + ret_dict['state'] = disk_facts['status'] + ret_dict['account_id'] = disk_facts['accountId'] + ret_dict['sep_id'] = disk_facts['sepid'] + ret_dict['pool'] = disk_facts['pool'] + ret_dict['attached_to'] = disk_facts['vmid'] + ret_dict['gid'] = disk_facts['gid'] + + return ret_dict + +def decort_disk_parameters(): + """Build and return a dictionary of parameters expected by decort_disk module in a form accepted + by AnsibleModule utility class.""" + + return dict( + account_id=dict(type='int', required=False, default=0), + account_name=dict(type='str', required=False, default=''), + annotation=dict(type='str', required=False, default=''), + app_id=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_APP_ID'])), + app_secret=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_APP_SECRET']), + no_log=True), + authenticator=dict(type='str', + required=True, + choices=['legacy', 'oauth2', 'jwt']), + controller_url=dict(type='str', required=True), + disk_id=dict(type='int', required=False, default=0), + disk_name=dict(type='str', required=False), + force_detach=dict(type='bool', required=False, default=False), + jwt=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_JWT']), + no_log=True), + oauth2_url=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_OAUTH2_URL'])), + password=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_PASSWORD']), + no_log=True), + place_with=dict(type='int', required=False, default=0), + pool=dict(type='str', required=False, default='default'), + sep_id=dict(type='int', required=False, default=0), + size=dict(type='int', required=False), + state=dict(type='str', + default='present', + choices=['absent', 'present']), + user=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_USER'])), + verify_ssl=dict(type='bool', required=False, default=True), + workflow_callback=dict(type='str', required=False), + workflow_context=dict(type='str', required=False), + ) + +# Workflow digest: +# 1) authenticate to DECORT controller & validate authentication by issuing API call - done when creating DECORTController +# 2) check if the ViNS with this id or name exists under specified account / resource group +# 3) if ViNS does not exist -> deploy +# 4) if ViNS exists: check desired state, desired configuration -> initiate action(s) accordingly +# 5) report result to Ansible + +def main(): + module_parameters = decort_disk_parameters() + + amodule = AnsibleModule(argument_spec=module_parameters, + supports_check_mode=True, + mutually_exclusive=[ + ['oauth2', 'password'], + ['password', 'jwt'], + ['jwt', 'oauth2'], + ], + required_together=[ + ['app_id', 'app_secret'], + ['user', 'password'], + ], + ) + + decon = DecortController(amodule) + + disk_id = 0 + disk_facts = None # will hold Disk facts + validated_acc_id = 0 + acc_facts = None # will hold Account facts + + if amodule.params['disk_id']: + # expect existing Disk with the specified ID + # This call to disk_find will abort the module if no Disk with such ID is present + disk_id, disk_facts = decon.disk_find(amodule.params['disk_id']) + if not disk_id: + decon.result['failed'] = True + decon.result['msg'] = "Specified Disk ID {} not found.".format(amodule.params['disk_id']) + amodule.fail_json(**decon.result) + validated_acc_id =disk_facts['accountId'] + elif (amodule.params['account_id'] or amodule.params['account_name'] != "") and amodule.params['disk_name'] != "": + # Make sure disk name is specified, if not - fail the module + if amodule.params['disk_name'] == "": + decon.result['failed'] = True + decon.result['msg'] = ("Cannot manage disk if both ID is 0 and disk name is empty.") + amodule.fail_json(**decon.result) + # Specified account must be present and accessible by the user, otherwise abort the module + validated_acc_id, acc_facts = decon.account_find(amodule.params['account_name'], amodule.params['account_id']) + if not validated_acc_id: + decon.result['failed'] = True + decon.result['msg'] = ("Current user does not have access to the requested account " + "or non-existent account specified.") + amodule.fail_json(**decon.result) + # This call to disk_find may return disk_id=0 if no Disk with this name found in + disk_id, disk_facts = decon.disk_find(disk_id=0, disk_name=amodule.params['disk_name'], + account_id=validated_acc_id, + check_state=False) + else: + # this is "invalid arguments combination" sink + # if we end up here, it means that module was invoked with disk_id=0 and undefined account + decon.result['failed'] = True + if amodule.params['account_id'] == 0 and amodule.params['account_name'] == "": + decon.result['msg'] = "Cannot find Disk by name when account name is empty and account ID is 0." + if amodule.params['disk_name'] == "": + decon.result['msg'] = "Cannot find Disk by empty name." + amodule.fail_json(**decon.result) + + # + # Initial validation of module arguments is complete + # + # At this point non-zero disk_id means that we will be managing pre-existing Disk + # Otherwise we are about to create a new disk + # + # Valid Disk model statii are as follows: + # + # "CREATED", "ASSIGNED", DELETED", "DESTROYED", "PURGED" + # + + disk_should_exist = False + + if disk_id: + disk_should_exist = True + if disk_facts['status'] in ["MODELED", "CREATING" ]: + # error: nothing can be done to existing Disk in the listed statii regardless of + # the requested state + decon.result['failed'] = True + decon.result['changed'] = False + decon.result['msg'] = ("No change can be done for existing Disk ID {} because of its current " + "status '{}'").format(disk_id, disk_facts['status']) + elif disk_facts['status'] in ["CREATED", "ASSIGNED"]: + if amodule.params['state'] == 'absent': + decon.disk_delete(disk_id, True, amodule.params['force_detach']) # delete permanently + disk_facts['status'] = 'DESTROYED' + disk_should_exist = False + elif amodule.params['state'] == 'present': + # resize Disk as necessary & if possible + if decon.check_amodule_argument('size', False): + decon.disk_resize(disk_facts, amodule.params['size']) + elif disk_facts['status'] == "DELETED": + if amodule.params['state'] == 'present': + # restore + decon.disk_restore(disk_id) + _, disk_facts = decon.disk_find(disk_id) + decon.disk_resize(disk_facts, amodule.params['size']) + disk_should_exist = True + elif amodule.params['state'] == 'absent': + # destroy permanently + decon.disk_delete(disk_id, permanently=True) + disk_facts['status'] = 'DESTROYED' + disk_should_exist = False + elif disk_facts['status'] in ["DESTROYED", "PURGED"]: + if amodule.params['state'] == 'present': + # Need to re-provision this Disk. + # Some attributes may change, some must stay the same: + # - disk name - stays, take from disk_facts + # - account ID - stays, take from validated account ID + # - size - may change, take from module arguments + # - SEP ID - may change, build based on module arguments + # - pool - may change, take from module arguments + # - annotation - may change, take from module arguments + # + # First validate required parameters: + decon.check_amodule_argument('size') # this will fail the module if size is not specified + target_sep_id = 0 + if decon.check_amodule_argument('sep_id', False) and amodule.params['sep_id'] > 0: + # non-zero sep_id is explicitly passed in module arguments + target_sep_id = amodule.params['sep_id'] + elif decon.check_amodule_argument('place_with', False) and amodule.params['place_with'] > 0: + # request to place this disk on the same SEP as the specified OS image + # validate specified OS image and assign SEP ID accordingly + image_id, image_facts = decon.image_find() + pass + else: + # no new SEP ID is explicitly specified, and no place_with option - use sep_id from the disk_facts + target_sep_id = disk_facts['sepid'] + disk_id = decon.disk_provision(disk_name=disk_facts['name'], # as this disk was found, its name is in the facts + size=amodule.params['size'], + account_id=validated_acc_id, + sep_id=target_sep_id, + pool=amodule.params['pool'], + desc=amodule.params['annotation'], + location="") + disk_should_exist = True + elif amodule.params['state'] == 'absent': + # nop + decon.result['failed'] = False + decon.result['changed'] = False + decon.result['msg'] = ("No state change required for Disk ID {} because of its " + "current status '{}'").format(disk_id, + disk_facts['status']) + disk_should_exist = False + else: + # disk_id =0 -> pre-existing Disk was not found. + disk_should_exist = False # we will change it back to True if Disk is created successfully + # If requested state is 'absent' - nothing to do + if amodule.params['state'] == 'absent': + decon.result['failed'] = False + decon.result['changed'] = False + decon.result['msg'] = ("Nothing to do as target state 'absent' was requested for " + "non-existent Disk name '{}'").format(amodule.params['disk_name']) + elif amodule.params['state'] == 'present': + decon.check_amodule_argument('disk_name') + # as we already have account ID, we can create Disk and get disk_id on success + # + # TODO: implement SEP ID selction logic + # + disk_id = decon.disk_provision(disk_name=disk_facts['name'], # as this disk was found, its name is in the facts + size=amodule.params['size'], + account_id=validated_acc_id, + sep_id=target_sep_id, + pool=amodule.params['pool'], + desc=amodule.params['annotation'], + location="") + disk_should_exist = True + elif amodule.params['state'] == 'disabled': + decon.result['failed'] = True + decon.result['changed'] = False + decon.result['msg'] = ("Invalid target state '{}' requested for non-existent " + "Disk name '{}'").format(amodule.params['state'], + amodule.params['disk_name']) + + # + # conditional switch end - complete module run + # + if decon.result['failed']: + amodule.fail_json(**decon.result) + else: + # prepare Disk facts to be returned as part of decon.result and then call exit_json(...) + if disk_should_exist: + if decon.result['changed']: + # If we arrive here, there is a good chance that the Disk is present - get fresh Disk + # facts by Disk ID. + # Otherwise, Disk facts from previous call (when the Disk was still in existence) will + # be returned. + _, disk_facts = decon.disk_find(disk_id) + decon.result['facts'] = decort_disk_package_facts(disk_facts, amodule.check_mode) + amodule.exit_json(**decon.result) + + +if __name__ == "__main__": + main() diff --git a/library/decort_kvmvm.py b/library/decort_kvmvm.py new file mode 100644 index 0000000..86694c1 --- /dev/null +++ b/library/decort_kvmvm.py @@ -0,0 +1,876 @@ +#!/usr/bin/python +# +# Digital Enegry Cloud Orchestration Technology (DECORT) modules for Ansible +# Copyright: (c) 2018-2020 Digital Energy Cloud Solutions LLC +# +# Apache License 2.0 (see http://www.apache.org/licenses/LICENSE-2.0.txt) +# + +# +# Author: Sergey Shubin (sergey.shubin@digitalenergy.online) +# + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: decort_kvmvm +short_description: Manage KVM virtual machine in DECORT cloud +description: > + This module can be used to create a KVM based virtual machine in Digital Energy cloud platform from a + specified OS image, modify virtual machine's CPU and RAM allocation, change its power state, configure + network port forwarding rules, restart guest OS and delete a virtual machine thus releasing + corresponding cloud resources. +version_added: "2.2" +author: + - Sergey Shubin +requirements: + - python >= 2.6 + - PyJWT module + - requests module + - decort_utils utility library (module) + - DECORT cloud platform version 3.4.1 or higher +notes: + - Environment variables can be used to pass selected parameters to the module, see details below. + - Specified Oauth2 provider must be trusted by the DECORT cloud controller on which JWT will be used. + - 'Similarly, JWT supplied in I(authenticator=jwt) mode should be received from Oauth2 provider trusted by + the DECORT cloud controller on which this JWT will be used.' +options: + account_id: + description: + - 'ID of the account in which this VM will be created (for new VMs) or is located (for already + existing VMs). This is the alternative to I(account_name) option.' + - If both I(account_id) and I(account_name) specified, then I(account_name) is ignored. + - If any one of I(vm_id) or I(rg_id) specified, I(account_id) is ignored. + required: no + account_name: + description: + - 'Name of the account in which this VM will be created (for new VMs) or is located (for already + existing VMs).' + - This parameter is ignored if I(account_id) is specified. + - If any one of I(vm_id) or I(rg_id) specified, I(account_name) is ignored. + required: no + annotation: + description: + - Optional text description of this VM. + default: empty string + required: no + app_id: + description: + - 'Application ID for authenticating to the DECORT controller when I(authenticator=oauth2).' + - 'Required if I(authenticator=oauth2).' + - 'If not found in the playbook or command line arguments, the value will be taken from DECORT_APP_ID + environment variable.' + required: no + app_secret: + description: + - 'Application API secret used for authenticating to the DECORT controller when I(authenticator=oauth2).' + - This parameter is required when I(authenticator=oauth2) and ignored in other modes. + - 'If not found in the playbook or command line arguments, the value will be taken from DECORT_APP_SECRET + environment variable.' + required: no + arch: + description: + - Architecture of the KVM VM. DECORT supports KVM hosts based on Intel x86 and IBM PowerPC hardware. + - This parameter is used when new KVM VM is created and ignored for all other operations. + - Module may fail if your DECORT installation does not have physical nodes of specified architecture. + default: KVM_X86 + choices: [ KVM_X86, KVM_PPC ] + required: yes + authenticator: + description: + - Authentication mechanism to be used when accessing DECORT controller and authorizing API call. + default: jwt + choices: [ jwt, oauth2, legacy ] + required: yes + boot_disk: + description: + - 'Boot disk size in GB. If this parameter is not specified for a new VM, the size of the boot disk + will be set to the size of the OS image, which this VM is based on.' + - Boot disk is always created in the same storage and pool, as the OS image, which this VM is based on. + - Boot disk cannot be detached from VM. + required: no + controller_url: + description: + - URL of the DECORT controller that will be contacted to manage the VM according to the specification. + - 'This parameter is always required regardless of the specified I(authenticator) type.' + required: yes + cpu: + description: + - Number of virtual CPUs to allocate for the VM. + - This parameter is required for creating new VM and optional for other operations. + - 'If you set this parameter for an existing VM, then the module will check if VM resize is necessary and do + it accordingly. Note that resize operation on a running VM may generate errors as not all OS images support + hot resize feature.' + required: no + id: + description: + - ID of the VM. + - 'Either I(id) or a combination of VM name I(name) and RG related parameters (either I(rg_id) or a pair of + I(account_name) and I(rg_name) is required to manage an existing VM.' + - 'This parameter is not required (and ignored) when creating new VM as VM ID is assigned by cloud platform + automatically and cannot be changed afterwards. If existing VM is identified by I(id), then I(account_id), + I(account_name), I(rg_name) or I(rg_id) parameters will be ignored.' + required: no + image_id: + description: + - ID of the OS image to use for VM provisioning. + - 'This parameter is valid at VM creation time only and is ignored for operations on existing VMs.' + - 'You need to know image ID, e.g. by extracting it with decort_osimage module and storing + in a variable prior to calling decort_kvmvm.' + - 'If both I(image_id) and I(image_name) are specified, I(image_name) will be ignored.' + required: no + image_name: + description: + - Name of the OS image to use for a new VM provisioning. + - 'This parameter is valid at VM creation time only and is ignored for operations on existing VMs.' + - 'The specified image name will be looked up in the target DECORT controller and error will be generated if + no matching image is found.' + - 'If both I(image_id) and I(image_name) are specified, I(image_name) will be ignored.' + required: no + jwt: + description: + - 'JWT (access token) for authenticating to the DECORT controller when I(authenticator=jwt).' + - 'This parameter is required if I(authenticator=jwt) and ignored for other authentication modes.' + - If not specified in the playbook, the value will be taken from DECORT_JWT environment variable. + required: no + name: + description: + - Name of the VM. + - 'To manage VM by I(name) you also need to specify either I(rg_id) or a pair of I(rg_name) and I(account_name).' + - 'If both I(name) and I(id) are specified, I(name) will be ignored and I(id) used to locate the VM.' + required: no + networks: + description: + - List of dictionaries that specifies network connections for this VM. + - Structure of each element is as follows: + - ' - (string) type - type of the network connection. Supported types are VINS and EXTNET.' + - ' - (int) id - ID of the target network segment. It is ViNS ID for I(net_type=VINS) and + external network segment ID for I(net_type=EXTNET)' + - ' - (string) ip_addr - optional IP address to request for this connection. If not specified, the + platform will assign valid IP address automatically.' + - 'If you call decort_kvmvm module for an existing VM, the module will try to reconfigure existing network + connections according to the new specification.' + - If this parameter is not specified, the VM will have no connections to the network(s). + required: no + oauth2_url: + description: + - 'URL of the oauth2 authentication provider to use when I(authenticator=oauth2).' + - 'This parameter is required when when I(authenticator=oauth2).' + - If not specified in the playbook, the value will be taken from DECORT_OAUTH2_URL environment variable. + password: + description: + - 'Password for authenticating to the DECORT controller when I(authenticator=legacy).' + - 'This parameter is required if I(authenticator=legacy) and ignored in other authentication modes.' + - If not specified in the playbook, the value will be taken from DECORT_PASSWORD environment variable. + required: no + ram: + description: + - Size of RAM in MB to allocate to the VM. + - This parameter is required for creating new VM and optional for other operations. + - 'If you set this parameter for an existing VM, then the module will check if VM resize is necessary and do + it accordingly. Note that resize operation on a running VM may generate errors as not all OS images support + hot resize feature.' + required: no + ssh_key: + description: + - 'SSH public key to be deployed on to the new VM for I(ssh_key_user). If I(ssh_key_user) is not specified, + the key will not be deployed, and a warning is generated.' + - This parameter is valid at VM creation time only and ignored for any operation on existing VMs. + required: no + ssh_key_user: + description: + - User for which I(ssh_key) should be deployed. + - If I(ssh_key) is not specified, this parameter is ignored and a warning is generated. + - This parameter is valid at VM creation time only and ignored for any operation on existing VMs. + required: no + state: + description: + - Specify the desired state of the virtual machine at the exit of the module. + - 'Regardless of I(state), if VM exists and is in one of [MIGRATING, DESTROYING, ERROR] states, do nothing.' + - 'If desired I(state=check):' + - ' - Just check if VM exists in any state and return its current specifications.' + - ' - If VM does not exist, fail the task.' + - 'If desired I(state=present):' + - ' - VM does not exist, create the VM according to the specifications and start it.' + - ' - VM in one of [RUNNING, PAUSED, HALTED] states, attempt resize if necessary, change network if necessary.' + - ' - VM in DELETED state, restore and start it.' + - ' - VM in DESTROYED state, recreate the VM according to the specifications and start it.' + - 'If desired I(state=poweredon):' + - ' - VM does not exist, create it according to the specifications.' + - ' - VM in RUNNING state, attempt resize if necessary, change network if necessary.' + - ' - VM in one of [PAUSED, HALTED] states, attempt resize if necessary, change network if necessary, next + start the VM.' + - ' - VM in DELETED state, restore it.' + - ' - VM in DESTROYED state, create it according to the specifications.' + - 'If desired I(state=absent):' + - ' - VM in one of [RUNNING, PAUSED, HALTED] states, destroy it.' + - ' - VM in one of [DELETED, DESTROYED] states, do nothing.' + - 'If desired I(state=paused):' + - ' - VM in RUNNING state, pause the VM, resize if necessary, change network if necessary.' + - ' - VM in one of [PAUSED, HALTED] states, resize if necessary, change network if necessary.' + - ' - VM in one of [DELETED, DESTROYED] states, abort with an error.' + - 'If desired I(state=poweredoff) or I(state=halted):' + - ' - VM does not exist, create the VM according to the specifications and leave it in HALTED state.' + - ' - VM in RUNNING state, stop the VM, resize if necessary, change network if necessary.' + - ' - VM in one of [PAUSED, HALTED] states, resize if necessary, change network if necessary.' + - ' - VM in DELETED state, abort with an error.' + - ' - VM in DESTROYED state, recreate the VM according to the specifications and leave it in HALTED state.' + default: present + choices: [ present, absent, poweredon, poweredoff, halted, paused, check ] + tags: + description: + - String of custom tags to be assigned to the VM (This feature is not implemented yet!). + - These tags are arbitrary text that can be used for grouping or indexing the VMs by other applications. + required: no + user: + description: + - 'Name of the legacy user for authenticating to the DECORT controller when I(authenticator=legacy).' + - 'This parameter is required when I(authenticator=legacy) and ignored for other authentication modes.' + - If not specified in the playbook, the value will be taken from DECORT_USER environment variable. + required: no + rg_id: + description: + - ID of the Resource Group where a new VM will be deployed or an existing VM can be found. + - 'This parameter may be required when managing VM by its I(name). If you specify I(rg_id), then + I(account_name), I(account_id) and I(rg_name) will be ignored.' + required: no + rg_name: + description: + - Name of the RG where the VM will be deployed (for new VMs) or can be found (for existing VMs). + - This parameter is required when managing VM by its I(name). + - If both I(rg_id) and I(rg_name) are specified, I(rg_name) will be ignored. + - If I(rg_name) is specified, then either I(account_name) or I(account_id) must also be set. + required: no + verify_ssl: + description: + - 'Controls SSL verification mode when making API calls to DECORT controller. Set it to False if you + want to disable SSL certificate verification. Intended use case is when you run module in a trusted + environment that uses self-signed certificates. Note that disabling SSL verification in any other + scenario can lead to security issues, so please know what you are doing.' + default: True + required: no + workflow_callback: + description: + - 'Callback URL that represents an application, which invokes this module (e.g. up-level orchestrator or + end-user portal) and may except out-of-band updates on progress / exit status of the module run.' + - API call at this URL will be used to relay such information to the application. + - 'API call payload will include module-specific details about this module run and I(workflow_context).' + required: no + workflow_context: + description: + - 'Context data that will be included into the payload of the API call directed at I(workflow_callback) URL.' + - 'This context data is expected to uniquely identify the task carried out by this module invocation so + that up-level orchestrator could match returned information to the its internal entities.' + required: no +''' + +EXAMPLES = ''' +- name: create a VM named "SimpleVM" in the DECORT cloud along with VDC named "ANewVDC" if it does not exist yet. + decort_kvmvm: + annotation: "VM managed by decort_kvmvm module" + authenticator: oauth2 + app_id: "{{ MY_APP_ID }}" + app_secret: "{{ MY_APP_SECRET }}" + controller_url: "https://ds1.digitalenergy.online" + name: SimpleVM + cpu: 2 + ram: 4096 + boot_disk: + size: 10 + model: ovs + pool: boot + image_name: "Ubuntu 16.04 v1.1" + data_disks: + - size: 50 + model: ovs + pool: data + port_forwards: + - ext_port: 21022 + int_port: 22 + proto: tcp + - ext_port: 80 + int_port: 80 + proto: tcp + state: present + tags: "PROJECT:Ansible STATUS:Test" + account_name: "Development" + rg_name: "ANewVDC" + delegate_to: localhost + register: simple_vm +- name: resize the above VM to CPU 4 and remove port forward rule for port number 80. + decort_kvmvm: + authenticator: jwt + jwt: "{{ MY_JWT }}" + controller_url: "https://ds1.digitalenergy.online" + name: SimpleVM + cpu: 4 + ram: 4096 + port_forwards: + - ext_port: 21022 + int_port: 22 + proto: tcp + state: present + account_name: "Development" + rg_name: "ANewVDC" + delegate_to: localhost + register: simple_vm +- name: stop existing VM identified by the VM ID and down size it to CPU:RAM 1:2048 along the way. + decort_kvmvm: + authenticator: jwt + jwt: "{{ MY_JWT }}" + controller_url: "https://ds1.digitalenergy.online" + id: "{{ TARGET_VM_ID }}" + cpu: 1 + ram: 2048 + state: poweredoff + delegate_to: localhost + register: simple_vm +- name: check if VM exists and read in its specs. + decort_kvmvm: + authenticator: oauth2 + app_id: "{{ MY_APP_ID }}" + app_secret: "{{ MY_APP_SECRET }}" + controller_url: "https://ds1.digitalenergy.online" + name: "{{ TARGET_VM_NAME }}" + rg_name: "{{ TARGET_VDC_NAME }}" + account_name: "{{ TRAGET_TENANT }}" + state: check + delegate_to: localhost + register: existing_vm +''' + +RETURN = ''' +facts: + description: facts about the virtual machine that may be useful in the playbook + returned: always + type: dict + sample: + facts: + id: 9454 + name: TestVM + state: RUNNING + username: testuser + password: Yab!tWbyPF + int_ip: 192.168.103.253 + rg_name: SandboxVDC + rg_id: 2883 + vdc_ext_ip: 185.193.143.151 + ext_ip: 185.193.143.106 + ext_netmask: 24 + ext_gateway: 185.193.143.1 + ext_mac: 52:54:00:00:1a:24 +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.decort_utils import * + +class decort_kvmvm(DecortController): + def __init__(self, arg_amodule): + # call superclass constructor first + super(decort_kvmvm, self).__init__(arg_amodule) + + self.comp_should_exist = False + self.comp_id = 0 + self.comp_info = None + self.acc_id = 0 + self.rg_id = 0 + + validated_acc_id =0 + validated_rg_id = 0 + validated_rg_facts = None + + # Analyze Compute name & ID, RG name & ID and build arguments to compute_find accordingly. + if arg_amodule.params['name'] == "" and arg_amodule.params['id'] == 0: + self.result['failed'] = True + self.result['changed'] = False + self.result['msg'] = "Cannot manage Compute when its ID is 0 and name is empty." + self.fail_json(**self.result) + # fail the module - exit + + if not arg_amodule.params['id']: # manage Compute by name -> need RG identity + if not arg_amodule.params['rg_id']: # RG ID is not set -> locate RG by name -> need account ID + validated_acc_id, _ = self.account_find(arg_amodule.params['account_name'], + arg_amodule.params['account_id']) + if not validated_acc_id: + self.result['failed'] = True + self.result['changed'] = False + self.result['msg'] = ("Current user does not have access to the account ID {} / " + "name '{}' or non-existent account specified.").format(arg_amodule.params['account_id'], + arg_amodule.params['account_name']) + self.fail_json(**self.result) + # fail the module -> exit + # now validate RG + validated_rg_id, validated_rg_facts = self.rg_find(validated_acc_id, + arg_amodule.params['rg_id'], + arg_amodule.params['rg_name']) + if not validated_rg_id: + self.result['failed'] = True + self.result['changed'] = False + self.result['msg'] = "Cannot find RG ID {} / name '{}'.".format(arg_amodule.params['rg_id'], + arg_amodule.params['rg_name']) + self.fail_json(**self.result) + # fail the module - exit + + self.rg_id = validated_rg_id + arg_amodule.params['rg_id'] = validated_rg_id + arg_amodule.params['rg_name'] = validated_rg_facts['name'] + self.acc_id = validated_rg_facts['accountId'] + + # at this point we are ready to locate Compute, and if anything fails now, then it must be + # because this Compute does not exist or something goes wrong in the upstream API + # We call compute_find with check_state=False as we also consider the case when a Compute + # specified by account / RG / compute name never existed and will be created for the first time. + self.comp_id, self.comp_info, self.rg_id = self.compute_find(comp_id=arg_amodule.params['id'], + comp_name=arg_amodule.params['name'], + rg_id=validated_rg_id, + check_state=False) + + if self.comp_id: + if self.comp_info['status'] != 'DESTROYED' and self.comp_info['arch'] not in ["KVM_X86", "KVM_PPC"]: + # If we found a Compute in a non-DESTROYED state and it is not of type KVM_*, abort the module + self.result['failed'] = True + self.result['msg'] = ("Compute ID {} architecture '{}' is not supported by " + "decort_kvmvm module.").format(self.comp_id, + self.amodule.params['arch']) + self.amodule.fail_json(**self.result) + # fail the module - exit + self.comp_should_exist = True + self.acc_id = self.comp_info['accountId'] + + return + + def nop(self): + """No operation (NOP) handler for Compute management by decort_kvmvm module. + This function is intended to be called from the main switch construct of the module + when current state -> desired state change logic does not require any changes to + the actual Compute state. + """ + self.result['failed'] = False + self.result['changed'] = False + if self.comp_id: + self.result['msg'] = ("No state change required for Compute ID {} because of its " + "current status '{}'.").format(self.comp_id, self.comp_info['status']) + else: + self.result['msg'] = ("No state change to '{}' can be done for " + "non-existent Compute instance.").format(self.amodule.params['state']) + return + + def error(self): + """Error handler for Compute instance management by decort_kvmvm module. + This function is intended to be called when an invalid state change is requested. + Invalid means that the current is invalid for any operations on the Compute or the + transition from current to desired state is not technically possible. + """ + self.result['failed'] = True + self.result['changed'] = False + if self.comp_id: + self.result['msg'] = ("Invalid target state '{}' requested for Compute ID {} in the " + "current status '{}'.").format(self.comp_id, + self.amodule.params['state'], + self.comp_info['status']) + else: + self.result['msg'] = ("Invalid target state '{}' requested for non-existent Compute name '{}' " + "in RG ID {} / name '{}'").format(self.amodule.params['state'], + self.amodule.params['name'], + self.amodule.params['rg_id'], + self.amodule.params['rg_name']) + return + + def create(self): + """New Compute instance creation handler for decort_kvmvm module. + This function checks for the presence of required parameters and deploys a new KVM VM + Compute instance with the specified characteristics into the target Resource Group. + The target RG must exist. + """ + # the following parameters must be present: cpu, ram, image_id or image_name + # each of the following calls will abort if argument is missing + self.check_amodule_argument('cpu') + self.check_amodule_argument('ram') + + if self.amodule.params['arch'] not in ["KVM_X86", "KVM_PPC"]: + self.result['failed'] = True + self.result['msg'] = ("Unsupported architecture '{}' is specified for " + "KVM VM create.").format(self.amodule.params['arch']) + self.amodule.fail_json(**self.result) + # fail the module - exit + + validated_bdisk_size = 0 + + image_facts = None + # either image_name or image_id must be present + if self.check_amodule_argument('image_id', abort=False) and self.amodule.params['image_id'] > 0 : + # find image by image ID and account ID + # image_find(self, image_id, image_name, account_id, rg_id=0, sepid=0, pool=""): + _, image_facts = self.image_find(image_id=self.amodule.params['image_id'], + image_name="", + account_id=self.acc_id) + elif self.check_amodule_argument('image_name', abort=False) and self.amodule.params['image_name'] != "": + # find image by image name and account ID + _, image_facts = self.image_find(image_id=0, + image_name=self.amodule.params['image_name'], + account_id=self.acc_id) + else: + # neither image_name nor image_id are set - abort the script + self.result['failed'] = True + self.result['msg'] = "Missing both 'image_name' and 'image_id'. You need to specify one to create a Compute." + self.amodule.fail_json(**self.result) + # fail the module - exit + + if ((not self.check_amodule_argument('boot_disk', False)) or + self.amodule.params['boot_disk'] <= image_facts['size']): + # adjust disk size to the minimum allowed by OS image, which will be used to spin off this Compute + validated_bdisk_size = image_facts['size'] + else: + validated_bdisk_size =self.amodule.params['boot_disk'] + + start_compute = True + if self.amodule.params['state'] in ('halted', 'poweredoff'): + start_compute = False + + if self.amodule.params['ssh_key'] and self.amodule.params['ssh_key_user']: + cloud_init_params = {'users': [ + {"name": self.amodule.params['ssh_key_user'], + "ssh-authorized-keys": [self.amodule.params['ssh_key']], + "shell": '/bin/bash'} + ]} + else: + cloud_init_params = None + + # if we get through here, all parameters required to create new Compute instance should be at hand + + self.comp_id = self.compute_provision(rg_id=self.rg_id, + comp_name=self.amodule.params['name'], arch=self.amodule.params['arch'], + cpu=self.amodule.params['cpu'], ram=self.amodule.params['ram'], + boot_disk=validated_bdisk_size, + image_id=image_facts['id'], + annotation=self.amodule.params['annotation'], + userdata=cloud_init_params, + start_on_create=start_compute) + self.comp_should_exist = True + + # + # Compute was created + # + # Setup network connections + self.compute_networks(self.comp_info, self.amodule.params['networks']) + # Next manage data disks + self.compute_data_disks(self.comp_info, self.amodule.params['data_disks']) + # read in Compute facts after all initial setup is complete + _, self.comp_info, _ = self.compute_find(comp_id=self.comp_id) + + return + + def destroy(self): + """Compute destroy handler for VM management by decort_kvmvm module. + Note that this handler deletes the VM permanently together with all assigned disk resources. + """ + self.compute_delete(comp_id=self.comp_id, permanently=True) + self.comp_info['status'] = 'DESTROYED' + self.comp_should_exist = False + return + + def restore(self): + """Compute restore handler for Compute instance management by decort_kvmvm module. + Note that restoring Compute is only possible if it is in DELETED state. If called on a + Compute instance in any other state, the method will throw an error and abort the execution + of the module. + """ + self.compute_restore(comp_id=self.comp_id) + # TODO - do we need updated comp_info to manage port forwards and size after VM is restored? + _, self.comp_info, _ = self.compute_find(comp_id=self.comp_id) + self.modify() + self.comp_should_exist = True + return + + def modify(self, arg_wait_cycles=0): + """Compute modify handler for KVM VM management by decort_kvmvm module. + This method is a convenience wrapper that calls individual Compute modification functions from + DECORT utility library (module). + """ + self.compute_networks(self.comp_info, self.amodule.params['networks']) + self.compute_bootdisk_size(self.comp_info, self.amodule.params['boot_disk']) + self.compute_data_disks(self.comp_info, self.amodule.params['data_disks']) + self.compute_resize(self.comp_info, + self.amodule.params['cpu'], self.amodule.params['ram'], + wait_for_state_change=arg_wait_cycles) + return + + def package_facts(self, check_mode=False): + """Package a dictionary of KVM VM facts according to the decort_kvmvm module specification. + This dictionary will be returned to the upstream Ansible engine at the completion of decort_kvmvm + module run. + + @param check_mode: boolean that tells if this Ansible module is run in check mode + + @return: dictionary of KVM VM facts, containing suffucient information to manage the KVM VM in + subsequent Ansible tasks. + """ + + ret_dict = dict(id=0, + name="", + arch="", + cpu="", + ram="", + disk_size=0, + data_disks=[], # IDs of attached data disks; this list can be emty + state="CHECK_MODE", + account_id=0, + rg_id=0, + username="", + password="", + public_ips=[], # direct IPs; this list can be empty + private_ips=[], # IPs on ViNSes; usually, at least one IP is listed + nat_ip="", # IP of the external ViNS interface; can be empty. + ) + + if check_mode or self.comp_info is None: + # if in check mode (or void facts provided) return immediately with the default values + return ret_dict + + # if not self.comp_should_exist: + # ret_dict['state'] = "ABSENT" + # return ret_dict + + ret_dict['id'] = self.comp_info['id'] + ret_dict['name'] = self.comp_info['name'] + ret_dict['arch'] = self.comp_info['arch'] + ret_dict['state'] = self.comp_info['status'] + ret_dict['account_id'] = self.comp_info['accountId'] + ret_dict['rg_id'] = self.comp_info['rgId'] + # if the VM is an imported VM, then the 'accounts' list may be empty, + # so check for this case before trying to access login and passowrd values + if len(self.comp_info['osUsers']): + ret_dict['username'] = self.comp_info['osUsers'][0]['login'] + ret_dict['password'] = self.comp_info['osUsers'][0]['password'] + + if self.comp_info['interfaces']: + # We need a list of all ViNSes in the account, which owns this Compute + # to find a ViNS, which may have active external connection. Then + # we will save external IP address of that connection in ret_dict['nat_ip'] + + for iface in self.comp_info['interfaces']: + if iface['connType'] == "VXLAN": # This is ViNS connection + ret_dict['private_ips'].append(iface['ipAddress']) + # if iface['connId'] + # Now we need to check if this ViNS has GW function and external connection. + # If it does - save public IP address of GW VNF in ret_dict['nat_ip'] + elif iface['connType'] == "VLAN": # This is direct external network connection + ret_dict['public_ips'].append(iface['ipAddress']) + + ret_dict['cpu'] = self.comp_info['cpus'] + ret_dict['ram'] = self.comp_info['ram'] + + ret_dict['image_id'] = self.comp_info['imageId'] + + for ddisk in self.comp_info['disks']: + if ddisk['type'] == 'B': + # if it is a boot disk - store its size + ret_dict['disk_size'] = ddisk['maxSize'] + elif ddisk['type'] == 'D': + # if it is a data disk - append its ID to the list of data disks IDs + ret_dict['data_disks'].append(ddisk['id']) + + return ret_dict + + @staticmethod + def build_parameters(): + """Build and return a dictionary of parameters expected by decort_kvmvm module in a form + accepted by AnsibleModule utility class. + This dictionary is then used y AnsibleModule class instance to parse and validate parameters + passed to the module from the playbook. + """ + + return dict( + account_id=dict(type='int', required=False, default=0), + account_name=dict(type='str', required=False, default=''), + annotation=dict(type='str', + default='', + required=False), + app_id=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_APP_ID'])), + app_secret=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_APP_SECRET']), + no_log=True), + arch=dict(type='str', choices=['KVM_X86', 'KVM_PPC'], default='KVM_X86'), + authenticator=dict(type='str', + required=True, + choices=['legacy', 'oauth2', 'jwt']), + boot_disk=dict(type='int', required=False), + controller_url=dict(type='str', required=True), + # count=dict(type='int', required=False, default=1), + cpu=dict(type='int', required=False), + # datacenter=dict(type='str', required=False, default=''), + data_disks=dict(type='list', default=[], required=False), # list of integer disk IDs + id=dict(type='int', required=False, default=0), + image_id=dict(type='int', required=False), + image_name=dict(type='str', required=False), + jwt=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_JWT']), + no_log=True), + name=dict(type='str'), + networks=dict(type='list', default=[], required=False), # list of dictionaries + oauth2_url=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_OAUTH2_URL'])), + password=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_PASSWORD']), + no_log=True), + ram=dict(type='int', required=False), + rg_id=dict(type='int', default=0), + rg_name=dict(type='str', default=""), + ssh_key=dict(type='str', required=False), + ssh_key_user=dict(type='str', required=False), + state=dict(type='str', + default='present', + choices=['absent', 'paused', 'poweredoff', 'halted', 'poweredon', 'present', 'check']), + tags=dict(type='str', required=False), + user=dict(type='str', + required=False, + fallback=(env_fallback, ['DECORT_USER'])), + verify_ssl=dict(type='bool', required=False, default=True), + # wait_for_ip_address=dict(type='bool', required=False, default=False), + workflow_callback=dict(type='str', required=False), + workflow_context=dict(type='str', required=False), + ) + +# Workflow digest: +# 1) authenticate to DECORT controller & validate authentication by issuing API call - done when creating DECSController +# 2) check if the VM with the specified id or rg_name:name exists +# 3) if VM does not exist, check if there is enough resources to deploy it in the target account / vdc +# 4) if VM exists: check desired state, desired configuration -> initiate action accordingly +# 5) VM does not exist: check desired state -> initiate action accordingly +# - create VM: check if target VDC exists, create VDC as necessary, create VM +# - delete VM: delete VM +# - change power state: change as required +# - change guest OS state: change as required +# 6) report result to Ansible + +def main(): + module_parameters = decort_kvmvm.build_parameters() + + amodule = AnsibleModule(argument_spec=module_parameters, + supports_check_mode=True, + mutually_exclusive=[ + ['oauth2', 'password'], + ['password', 'jwt'], + ['jwt', 'oauth2'], + ], + required_together=[ + ['app_id', 'app_secret'], + ['user', 'password'], + ], + required_one_of=[ + ['id', 'name'], + ], + ) + + # Initialize DECORT KVM VM instance object + # This object does not necessarily represent an existing KVM VM + subj = decort_kvmvm(amodule) + + # handle state=check before any other logic + if amodule.params['state'] == 'check': + subj.result['changed'] = False + if subj.comp_id: + # Compute is found - package facts and report success to Ansible + subj.result['failed'] = False + subj.comp_info = subj.compute_find(comp_id=subj.comp_id) + _, rg_facts = subj.rg_find(rg_id=subj.rg_id) + subj.result['facts'] = subj.package_facts(rg_facts, amodule.check_mode) + amodule.exit_json(**subj.result) + # we exit the module at this point + else: + subj.result['failed'] = True + subj.result['msg'] = ("Cannot locate Compute name '{}'. Other arguments are: Compute ID {}, " + "RG name '{}', RG ID {}, Account '{}'.").format(amodule.params['name'], + amodule.params['id'], + amodule.params['rg_name'], + amodule.params['rg_id'], + amodule.params['account_name']) + amodule.fail_json(**subj.result) + pass + + if subj.comp_id: + if subj.comp_info['status'] in ("MIGRATING", "DESTROYING", "ERROR"): + # nothing to do for an existing Compute in the listed states regardless of the requested state + subj.nop() + elif subj.comp_info['status'] == "RUNNING": + if amodule.params['state'] == 'absent': + subj.destroy() + elif amodule.params['state'] in ('present', 'poweredon'): + # check port forwards / check size / nop + subj.modify() + elif amodule.params['state'] in ('paused', 'poweredoff', 'halted'): + # pause or power off the vm, then check port forwards / check size + subj.compute_powerstate(subj.comp_info, amodule.params['state']) + subj.modify(arg_wait_cycles=7) + elif subj.comp_info['status'] in ("PAUSED", "HALTED"): + if amodule.params['state'] == 'absent': + subj.destroy() + elif amodule.params['state'] in ('present', 'paused', 'poweredoff', 'halted'): + subj.modify() + elif amodule.params['state'] == 'poweredon': + subj.modify() + subj.compute_powerstate(subj.comp_info, amodule.params['state']) + elif subj.comp_info['status'] == "DELETED": + if amodule.params['state'] in ('present', 'poweredon'): + # TODO - check if restore API returns VM ID (similarly to VM create API) + subj.compute_restore(comp_id=subj.comp_id) + # TODO - do we need updated comp_info to manage port forwards and size after VM is restored? + _, subj.comp_info, _ = subj.compute_find(comp_id=subj.comp_id) + subj.modify() + elif amodule.params['state'] == 'absent': + subj.nop() + subj.comp_should_exist = False + elif amodule.params['state'] in ('paused', 'poweredoff', 'halted'): + subj.error() + elif subj.comp_info['status'] == "DESTROYED": + if amodule.params['state'] in ('present', 'poweredon', 'poweredoff', 'halted'): + subj.create() + # TODO: Network setup? + # TODO: Data disks setup? + elif amodule.params['state'] == 'absent': + subj.nop() + subj.comp_should_exist = False + elif amodule.params['state'] == 'paused': + subj.error() + else: + # Preexisting Compute of specified identity was not found. + # If requested state is 'absent' - nothing to do + if amodule.params['state'] == 'absent': + subj.nop() + elif amodule.params['state'] in ('present', 'poweredon', 'poweredoff', 'halted'): + subj.create() + # TODO: Network setup? + # TODO: Data disks setup? + elif amodule.params['state'] == 'paused': + subj.error() + + if subj.result['failed']: + amodule.fail_json(**subj.result) + else: + # prepare Compute facts to be returned as part of decon.result and then call exit_json(...) + rg_facts = None + if subj.comp_should_exist: + if subj.result['changed']: + # There were changes to the Compute - refresh Compute facts. + _, subj.comp_info, _ = subj.compute_find(comp_id=subj.comp_id) + # + # TODO: check if we really need to get RG facts here in view of DNF implementation + # we need to extract RG facts regardless of 'changed' flag, as it is our source of information on + # the VDC external IP address + _, rg_facts = subj.rg_find(arg_rg_id=subj.rg_id) + subj.result['facts'] = subj.package_facts(rg_facts, amodule.check_mode) + amodule.exit_json(**subj.result) + + +if __name__ == "__main__": + main() diff --git a/library/decort_osimage.py b/library/decort_osimage.py index be50149..e144359 100644 --- a/library/decort_osimage.py +++ b/library/decort_osimage.py @@ -32,7 +32,7 @@ requirements: - PyJWT module - requests module - decort_utils utility library (module) - - DECORT cloud platform version 3.4.0 or higher. + - DECORT cloud platform version 3.4.1 or higher. notes: - Environment variables can be used to pass selected parameters to the module, see details below. - Specified Oauth2 provider must be trusted by the DECORT cloud controller on which JWT will be used. @@ -275,22 +275,24 @@ def main(): decon = DecortController(amodule) # we need account ID to locate OS images - find the account by the specified name and get its ID - account_id, _ = decon.account_find(amodule.params['account_name']) - if account_id == 0: + validated_account_id, _ = decon.account_find(amodule.params['account_name']) + if validated_account_id == 0: # we failed either to find or access the specified account - fail the module decon.result['failed'] = True decon.result['changed'] = False decon.result['msg'] = ("Cannot find account '{}'").format(amodule.params['account_name']) amodule.fail_json(**decon.result) - osimage_facts = decon.image_find(amodule.params['image_name'], 0, account_id, - amodule.params['sep_id'], amodule.params['pool']) + image_id, image_facts = decon.image_find(image_id=0, image_name=amodule.params['image_name'], + account_id=validated_account_id, rg_id=0, + sepid=amodule.params['sep_id'], + pool=amodule.params['pool']) if decon.result['failed'] == True: # we failed to find the specified image - fail the module decon.result['changed'] = False amodule.fail_json(**decon.result) - decon.result['facts'] = decort_osimage_package_facts(osimage_facts, amodule.check_mode) + decon.result['facts'] = decort_osimage_package_facts(image_facts, amodule.check_mode) decon.result['changed'] = False # decort_osimage is a read-only module - make sure the 'changed' flag is set to False amodule.exit_json(**decon.result) diff --git a/library/decort_vins.py b/library/decort_vins.py index 9606ef6..e649e91 100644 --- a/library/decort_vins.py +++ b/library/decort_vins.py @@ -29,7 +29,7 @@ requirements: - PyJWT module - requests module - decort_utils utility library (module) - - DECORT cloud platform version 3.4.0 or higher + - DECORT cloud platform version 3.4.1 or higher notes: - Environment variables can be used to pass selected parameters to the module, see details below. - Specified Oauth2 provider must be trusted by the DECORT cloud controller on which JWT will be used. @@ -38,8 +38,8 @@ notes: options: account_id: description: - - ID of the account under which this ViNS will be created (for new ViNS) or is located (for already - existing ViNS). This is the alternative to I(account_name) option. + - 'ID of the account under which this ViNS will be created (for new ViNS) or is located (for already + existing ViNS). This is the alternative to I(account_name) option.' - If both I(account_id) and I(account_name) specified, then I(account_name) is ignored. required: no account_name: @@ -246,10 +246,11 @@ from ansible.module_utils.decort_utils import * def decort_vins_package_facts(arg_vins_facts, arg_check_mode=False): - """Package a dictionary of RG facts according to the decort_vins module specification. This dictionary will - be returned to the upstream Ansible engine at the completion of the module run. + """Package a dictionary of ViNS facts according to the decort_vins module specification. + This dictionary will be returned to the upstream Ansible engine at the completion of + the module run. - @param arg_vins_facts: dictionary with RG facts as returned by API call to .../rg/get + @param arg_vins_facts: dictionary with viNS facts as returned by API call to .../vins/get @param arg_check_mode: boolean that tells if this Ansible module is run in check mode """ @@ -325,7 +326,6 @@ def decort_vins_parameters(): required=False, fallback=(env_fallback, ['DECORT_PASSWORD']), no_log=True), - quotas=dict(type='dict', required=False), state=dict(type='str', default='present', choices=['absent', 'disabled', 'enabled', 'present']), @@ -376,7 +376,7 @@ def main(): if amodule.params['vins_id']: # expect existing ViNS with the specified ID - # This call to rg_vins will abort the module if no ViNS with such ID is present + # This call to vins_find will abort the module if no ViNS with such ID is present vins_id, vins_facts = decon.vins_find(amodule.params['vins_id']) if not vins_id: decon.result['failed'] = True @@ -437,7 +437,7 @@ def main(): decon.result['failed'] = True if amodule.params['account_id'] == 0 and amodule.params['account_name'] == "": decon.result['msg'] = "Cannot find ViNS by name when account name is empty and account ID is 0." - if amodule.params['rg_name'] != "": + if amodule.params['rg_name'] == "": # rg_name without account specified decon.result['msg'] = "Cannot find ViNS by name when RG name is empty and RG ID is 0." decon.fail_json(**decon.result) @@ -555,9 +555,6 @@ def main(): decon.result['msg'] = ("Nothing to do as target state 'absent' was requested for " "non-existent ViNS name '{}'").format(amodule.params['vins_name']) elif amodule.params['state'] in ('present', 'enabled'): - # Target RG does not exist yet - create it and store the returned ID in rg_id variable for later use - # To create RG we need account name (or account ID) and RG name - check - # that these parameters are present and proceed. decon.check_amodule_argument('vins_name') # as we already have account ID and RG ID we can create ViNS and get vins_id on success vins_id = decon.vins_provision(amodule.params['vins_name'], diff --git a/module_utils/decort_utils.py b/module_utils/decort_utils.py index 772eca2..20f67af 100644 --- a/module_utils/decort_utils.py +++ b/module_utils/decort_utils.py @@ -27,6 +27,7 @@ It is not compatible with older versions. import copy import json import jwt +import netaddr import time import requests @@ -407,287 +408,242 @@ class DecortController(object): return None ################################### - # VM resource manipulation methods + # Compute and KVM VM resource manipulation methods ################################### - def vm_bootdisk_size(self, arg_vm_dict, arg_boot_disk): + def compute_bootdisk_size(self, comp_dict, new_size): """Manages size of the boot disk. Note that the size of the boot disk can only grow. This method will issue a warning if you try to reduce the size of the boot disk. - @param arg_vm_dict: dictionary with VM facts. It identifies the VM for which boot disk size change is - requested. - @param arg_boot_disk: dictionary that contains boot disk parameters. Only 'size' parameter will be used by - this method. All other keys, if any, will be ignored. + @param (int) comp_dict: dictionary with Compute facts. It identifies the Compute for which boot disk + size change is requested. + @param (int) new_size: new size of boot disk in GB. If new size is the same as the current boot disk size, + the method will do nothing. If new size is smaller, an error will occur. """ - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_bootdisk_size") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_bootdisk_size") if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = ("vm_bootdisk_size() in check mode: change boot disk size for VM ID {} " - "was requested.").format(arg_vm_dict['id']) - return - - if arg_boot_disk is None or 'size' not in arg_boot_disk: - self.result['failed'] = False - self.result['warning'] = ("vm_bootdisk_size(): no boot disk size specified for VM ID {}, skipping " - "the changes as there is nothing to do.").format(arg_vm_dict['id']) + self.result['msg'] = ("compute_bootdisk_size() in check mode: change boot disk size for Compute " + "ID {} was requested.").format(comp_dict['id']) return - bdisk_id = 0 bdisk_size = 0 - # we will look for the 1st occurence of what is expected to be a boot disk - for disk in arg_vm_dict['disks']: - if disk['type'] == "B" and disk['name'] == "Boot disk": - bdisk_id = disk['id'] + bdisk_id = 0 + for disk in comp_dict['disks']: + if disk['type'] == 'B': bdisk_size = disk['sizeMax'] + bdisk_id = disk['id'] break + else: + self.result['failed'] = True + self.result['msg'] = ("compute_bootdisk_size(): cannot identify boot disk for Compute " + "ID {}.").format(comp_dict['id']) + return - if not bdisk_id: + if new_size == bdisk_size: self.result['failed'] = False - self.result['warning'] = ("vm_bootdisk_size(): cannot identify boot disk of VM ID {}, skipping " - "the changes.").format(arg_vm_dict['id']) + self.result['warning'] = ("compute_bootdisk_size(): new size {} is the same as current for " + "Compute ID {}, nothing to do.").format(new_size, comp_dict['id']) return - - if bdisk_size >= int(arg_boot_disk['size']): # add cast to int in case new boot disk size comes as string + elif new_size < bdisk_size: self.result['failed'] = False - self.result['msg'] = ("vm_bootdisk_size(): new boot disk size {} for VM ID {} is not greater than the " - "current size {} - no changes done.").format(arg_boot_disk['size'], - arg_vm_dict['id'], - bdisk_size) + self.result['warning'] = ("compute_bootdisk_size(): new size {} is less than current {} for " + "Compute ID {}, skipping change.").format(new_size, bdisk_size, comp_dict['id']) return api_params = dict(diskId=bdisk_id, - size=arg_boot_disk['size']) + size=new_size) self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/resize", api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True - + for disk in comp_dict['disks']: + if disk['type'] == 'B': + disk['sizeMax'] = new_size + break return - def vm_delete(self, arg_vm_id, arg_permanently=False): - """Delete a VM identified by VM ID. It is assumed that the VM with the specified ID exists. - - @param arg_vm_id: an integer VM ID to be deleted - @param arg_permanently: a bool that tells if deletion should be permanent. If False, the VM will be - marked as deleted and placed into a "trash bin" for predefined period of time (usually, a few days). Until - this period passes the VM can be restored by calling 'restore' method. + def compute_data_disks(self, comp_dict, data_disks): + """Manage attachment of data disks to the Compute instance. + + @param (dict) comp_dict: dictionary with Compute facts, that identifies the Compute instance + to manage data disks for. + @param (list of int) data_disks: list of interger IDs for the disks that must be attached to + this Compute instance. If some disk IDs appear in this list, but are not present in comp_dict, + these disks will be attached. Vice versa, if some disks appear in comp_dict but are not present + in data_disks, such disks will be detached. + + Note: + 1) you cannot control boot disk attachment, so including this Compute's boot disk ID + into data_disk list will have no effect (as well as not listing it there). + 2) this function may modify data_disks by removing from it an ID that corresponds to + this Compute's boot disk (if found). + 3) In view of #2, upstream code may need to reread compute facts (com_dict) so that it + contains updated information about attached disks. """ - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_delete") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_data_disks") if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = "vm_delete() in check mode: destroy VM ID {} was requested.".format(arg_vm_id) + self.result['msg'] = ("compute_data_disks() in check mode: managing data disks on Compute " + "ID {} was requested.").format(comp_dict['id']) return - api_params = dict(machineId=arg_vm_id, - permanently=arg_permanently,) - self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/delete", api_params) - # On success the above call will return here. On error it will abort execution by calling fail_json. + bdisk_id = 0 + current_list = [] + detach_list = [] + attach_list = [] + + for disk in comp_dict['disks']: + if disk['type'] == 'B': + bdisk_id = disk['id'] + if bdisk_id in data_disks: + # If boot disk ID is listed in data_disks - remove it + data_disks.remove(bdisk_id) + elif disk['type'] == 'D': + # build manipulation sets for 'D' type disks only + current_list.append(disk['id']) + if disk['id'] not in data_disks: + detach_list.append(disk['id']) + + attach_list = [ did for did in data_disks if did not in current_list ] + + for did in detach_list: + api_params = dict(computeId = comp_dict['id'], diskId=did) + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/diskDetach", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + + for did in attach_list: + api_params = dict(computeId = comp_dict['id'], diskId=did) + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/diskAttach", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + self.result['failed'] = False self.result['changed'] = True return - def vm_extnetwork(self, arg_vm_dict, arg_desired_state, arg_ext_net_id=0, arg_force_delay=0): - """Manage external network allocation for the VM. - This method will either attach or detach external network IP address (aka direct IP address) to/from the - specified VM. - Only one external IP address can be present for each VM (this limitation may be removed in the future). - - @param arg_vm_dict: dictionary with VM facts. It identifies the VM for which external network IP address - configuration is requested. - @param arg_desired_state: specifies the desired state for the external network IP address attached to VM. - Valid values are 'present' or 'absent'. - @param arg_ext_net_id: specifies external network ID to get external IP address for this VM from. - @param arg_force_delay: if not 0, it tells the method to delay external network attachment for the number - of seconds passed in this argument. The use case for this is when external network is attached to a VM - during its creation and we need to make sure the guest OS is already started by the moment external - network and corresponding vNIC are attached to the VM. + def compute_delete(self, comp_id, permanently=False): + """Delete a Compute instance identified by its ID. It is assumed that the Compute with the specified + ID exists. + + @param (int) comp_id: ID of the Compute instance to be deleted + @param (bool) permanently: a bool that tells if deletion should be permanent. If False, the Compute + will be marked as deleted and placed into a "trash bin" for predefined period of time (usually, for a + few days). Until this period passes the Compute can be restored by calling 'restore' method. """ - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_extnetwork") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_delete") if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = "vm_extnetwork() in check mode: external network configuration change requested." - return - - # /cloudapi/externalnetwork/list - # accountId - required, integer - # - # NEW in 2.4.5+: /cloudapi/machines/listExternalNetworks - # machineId - required - # - # /cloudapi/machines/attachExternalNetwork - # machineId - required, integer - # NEW in : externalNetworkId - optional, integer - # Returns anything besides HTTP response? - # - # /cloudapi/machines/detachExternalNetwork - # machineId - required, integer - # NEW in : - # - - api_params = dict(machineId=arg_vm_dict['id']) - if arg_ext_net_id > 0: # better check, so that only positive IDs are acted upon - api_params['externalNetworkId'] = arg_ext_net_id - - ext_network_present = False - # look up external network in the provided arg_vm_dict - for item in arg_vm_dict['interfaces']: - if item['type'] == "PUBLIC": - if not arg_ext_net_id or (arg_ext_net_id > 0 and arg_ext_net_id == item['networkId']): - ext_network_present = True - break - - if arg_desired_state == 'present' and not ext_network_present: - api_url = "/restmachine/cloudapi/machines/attachExternalNetwork" - elif arg_desired_state == 'absent' and ext_network_present: - arg_force_delay = 0 # make sure there is no delay when we detach the network - api_url = "/restmachine/cloudapi/machines/detachExternalNetwork" - else: - self.result['failed'] = False - self.result['msg'] = ("vm_extnetwork(): no change required for external IP assignment to VM ID {}, " - "external IP presence flag {}, requested network ID {}, " - "requested state '{}'.").format(arg_vm_dict['id'], - ext_network_present, arg_ext_net_id, - arg_desired_state) + self.result['msg'] = "compute_delete() in check mode: delete Compute ID {} was requested.".format(comp_id) return - if arg_force_delay > 0: - # TODO: this is a quick fix for the case when we need guest OS started before - # the changes to the ext network will be recognized by the cloud init scripts and - # default gateways are reconfigured accordingly - time.sleep(arg_force_delay) - - self.decort_api_call(requests.post, api_url, api_params) + api_params = dict(computeId=comp_id, + permanently=permanently,) + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/delete", api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True - return - - def vm_facts(self, arg_vm_id=0, - arg_vm_name=None, arg_rg_id=0, arg_rg_name=None): - """Tries to find specified VM and returns its details on success. - Note: this method does not check if VM state is valid, so it is the responsibility of upstream code to do - such checks if necessary. - If VM is not found, no error is generated, just zero VM ID is returned. - - @param arg_vm_id: ID of the VM to locate. If 0 is passed as VM ID, then location will be done based on VM name - and VDC attributes. - @param arg_vm_name: name of the VM to locate. Locating VM by name requires that arg_vm_id is set to 0 and - either non-zero arg_rg_id is specified or non-empty arg_rg_name is specified. - @param arg_rg_id: ID of the RG to locate VM in. This parameter is used when locating VM by name and ignored - otherwise. - @param arg_rg_name: name of the RG to locate VM in. This parameter is used when locating VM by name and - ignored otherwise. It is also ignored if arg_rg_id is non-zero. - - @return: ret_vm_facts - dictionary with VM details on success, empty dictionary otherwise - """ - _, ret_vm_facts, _ = self.vm_find(arg_vm_id, arg_vm_name, - arg_rg_id, arg_rg_name, - arg_check_state=False) - - return ret_vm_facts + return - def _vm_get_by_id(self, arg_vm_id): - """Helper function that locates VM by ID and returns VM facts. + def _compute_get_by_id(self, comp_id): + """Helper function that locates compute instance by ID and returns Compute facts. - @param arg_vm_id: ID of the VM to find and return facts for. + @param (int) comp_id: ID of the Compute instance to find and return facts for. - @return: VM ID, dictionary of VM facts and VDC ID where this VM is located. Note that if it fails - to find the VM for the specified ID, it may return 0 for ID and empty dictionary for the facts. So - it is suggested to check the return values accordingly. + @return: (int) Compute ID, dictionary of Compute facts and RG ID where this Compute is located. Note + that if it fails to find the Compute for the specified ID, it may return 0 for ID and None for the + dictionary. So it is suggested to check the return values accordingly. """ - ret_vm_id = 0 - ret_vm_dict = dict() + ret_comp_id = 0 + ret_comp_dict = None ret_rg_id = 0 - api_params = dict(machineId=arg_vm_id,) - api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/get", api_params) + api_params = dict(computeId=comp_id,) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/get", api_params) if api_resp.status_code == 200: - ret_vm_id = arg_vm_id - ret_vm_dict = json.loads(api_resp.content.decode('utf8')) - ret_rg_id = ret_vm_dict['rgId'] + ret_comp_id = comp_id + ret_comp_dict = json.loads(api_resp.content.decode('utf8')) + ret_rg_id = ret_comp_dict['rgId'] else: - self.result['warning'] = ("vm_get_by_id(): failed to get VM by ID {}. HTTP code {}, " - "response {}.").format(arg_vm_id, api_resp.status_code, api_resp.reason) - - return ret_vm_id, ret_vm_dict, ret_rg_id - - - def vm_find(self, arg_vm_id=0, - arg_vm_name=None, arg_rg_id=0, arg_rg_name=None, - arg_check_state=True): - """Tries to find a VM the VM with the specified parameters. - Unlike other methods that also need to locate the VM, this method does not generate error if no VM matching - specified parameters was found. - On success returns VM ID and a dictionary with VM details, or 0 and emtpy dictionary on failure. - NOTE: even if this method fails to find a VM (and returns zero VM ID) it can still return non zero VDC ID if - it was successfully located by name. - - @param arg_vm_id: ID of the VM to locate. If 0 is passed as VM ID, then location will be done based on VM name - and VDC attributes. - @param arg_vm_name: name of the VM to locate. Locating VM by name requires that arg_vm_id is set to 0 and - either non-zero arg_rg_id is specified or non-empty arg_rg_name is specified. - @param arg_rg_id: ID of the rg to locate VM in. This parameter is used when locating VM by name and ignored - otherwise. - @param arg_rg_name: name of the RG to locate VM in. This parameter is used when locating VM by name and - ignored otherwise. It is also ignored if arg_rg_id is non-zero. - @param arg_check_state: check that VM in valid state if True. Note that this check is not done if non-zero - arg_vm_id is passed to the method. - - @return: ret_vm_id - ID of the VM on success (if the VM is found), 0 otherwise. - @return: ret_vm_dict - dictionary with VM details on success as returned by /cloudapi/machines/get, - empty dictionary otherwise. - @return: ret_rg_id - ID of the VDC where either VM was found or the ID of the VDC as requested by arguments. + self.result['warning'] = ("compute_get_by_id(): failed to get Compute by ID {}. HTTP code {}, " + "response {}.").format(comp_id, api_resp.status_code, api_resp.reason) + + return ret_comp_id, ret_comp_dict, ret_rg_id + + + def compute_find(self, comp_id, + comp_name="", rg_id=0, + check_state=True): + """Tries to find Compute instance according to the specified parameters. On success returns non-zero + Compute ID and a dictionary with Compute details, or 0 for ID and None for the dictionary on failure. + + @param (int) comp_id: ID of the Compute to locate. If non zero comp_id is passed, it is assumed that + this Compute exists (check_state flag is ignored). Also comp_name and rg_id are ignored, when searching + by Compute ID. + @param (string) comp_name: name of the Compute to locate. Locating Compute instance by name requires + that comp_id is set to 0 and non-zero rg_id is specified. + @param (int) rg_id: ID of the RG to locate Compute instance in. This parameter is used when locating + Compute by name and ignored otherwise. + @param (bool) check_state: check that VM in valid state if True. Note that this check is skpped if + non-zero comp_id is passed to the method. + + @return: (int) ret_comp_id - ID of the Compute on success (if the Compute was found), 0 otherwise. + @return: (dict) ret_comp_dict - dictionary with Compute details on success as returned by + /cloudapi/compute/get, None otherwise. + @return: (int) ret_rg_id - validated ID of the RG, where this Compute instance was found. """ - VM_INVALID_STATES = ["DESTROYED", "DELETED", "ERROR", "DESTROYING"] + COMP_INVALID_STATES = ["DESTROYED", "DELETED", "ERROR", "DESTROYING"] - ret_vm_id = 0 - ret_vm_dict = dict() - ret_rg_id = 0 - api_params = dict() + ret_comp_id = 0 + ret_comp_dict = None + validated_rg_id = 0 + validated_rg_facts = None - if arg_vm_id: - # locate VM by ID - if there is no VM with such ID, the below method will abort - ret_vm_id, ret_vm_dict, ret_rg_id = self._vm_get_by_id(arg_vm_id) - if not ret_vm_id: + if comp_id: + # locate Compute instance by ID - if there is no Compute with such ID, the method will abort + # upstream Ansible module execution by calling fail_json(...) + # Note that in this mode check_state argument is ignored. + ret_comp_id, ret_comp_dict, ret_rg_id = self._compute_get_by_id(comp_id) + if not ret_comp_id: self.result['failed'] = True - self.result['msg'] = "vm_find(): cannot locate VM with ID {}.".format(arg_vm_id) + self.result['msg'] = "compute_find(): cannot locate Compute with ID {}.".format(comp_id) self.amodule.fail_json(**self.result) + # fail the module - exit else: - # If no arg_vm_id specified, then we have to locate the target VDC. - # To locate VDC we need either non zero VDC ID or non empty VDC name - do corresponding sanity check - if not arg_rg_id and arg_rg_name == "": + # If no comp_id specified, then we have to locate Compute by combination of compute name and RG ID. + # To locate RG we need either non zero RG ID or non empty RG name - do corresponding sanity check + if not rg_id or comp_name == "": self.result['failed'] = True - self.result['msg'] = ("vm_find(): cannot locate VDC when 'rg_id' is zero and 'rg_name' is empty at " - "the same time.") + self.result['msg'] = "compute_find(): cannot find Compute by name when name is empty or RG ID is zero." self.amodule.fail_json(**self.result) - - ret_rg_id, _ = self.vdc_find(arg_rg_id, arg_rg_name) - if ret_rg_id: - # if we have non zero arg_rg_id at this point, try to find the VM in the corresponding VDC - api_params['rgId'] = ret_rg_id - api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/list", api_params) - if api_resp.status_code == 200: - vms_list = json.loads(api_resp.content.decode('utf8')) - for vm_record in vms_list: - if vm_record['name'] == arg_vm_name: - if not arg_check_state or vm_record['status'] not in VM_INVALID_STATES: - ret_vm_id = vm_record['id'] - _, ret_vm_dict, _ = self._vm_get_by_id(ret_vm_id) + # fail the module - exit + + validated_rg_id, validated_rg_facts = self._rg_get_by_id(rg_id) + if validated_rg_id: + # if we have validated RG ID at this point, look up Compute by name in this RG + # rg.vms list contains IDs of compute instances registered with this RG until compute is + # destroyed. So we may see here computes in "active" and DELETED states. + for runner in validated_rg_facts['vms']: + _, runner_dict, _ = self._compute_get_by_id(runner) + if runner_dict['name'] == comp_name: + if not check_state or runner_dict['status'] not in COMP_INVALID_STATES: + ret_comp_id = runner + ret_comp_dict = runner_dict + break else: - # ret_rg_id is still zero? - this should not happen in view of the validations we did above! - pass + # validated_rg_id is zero - seems that we've been given RG ID for non-existent resource group. + self.result['failed'] = True + self.result['msg'] = "compute_find(): cannot get RG ID {} to search for Compute by name.".format(rg_id) + self.amodule.fail_json(**self.result) + # fail the module - exit - return ret_vm_id, ret_vm_dict, ret_rg_id + return ret_comp_id, ret_comp_dict, validated_rg_id def vm_portforwards(self, arg_vm_dict, arg_pfw_specs): """Manage VM port forwarding rules in a smart way. This method takes desired port forwarding rules as @@ -840,16 +796,17 @@ class DecortController(object): self.result['changed'] = True return - def vm_powerstate(self, arg_vm_dict, arg_target_state, force_change=True): - """Manage VM power state transitions or its guest OS restart + def compute_powerstate(self, comp_facts, target_state, force_change=True): + """Manage Compute power state transitions or its guest OS restarts. - @param arg_vm_dict: dictionary with VM facts. It identifies the VM for which power state change is + @param (dict) comp_facts: dictionary with Compute instance facts, which power state change is requested. - @param arg_target_state: string that describes the desired power state of the VM. - @param force_change: boolean flag that tells if it is allowed to force power state transition for certain + @param (string) target_state: desired power state of this Compute. Allowed values are: + 'poweredon', 'poweredoff', 'paused', 'halted', 'restarted', + @param (bool) force_change: tells if it is allowed to force power state transition for certain cases (e.g. for transition into 'stop' state). - NOTE: this method may return before the actual change of target VM power state occurs. + NOTE: this method may return before the actual change of Compute's power state occurs. """ # @param wait_for_change: integer number that tells how many 5 seconds intervals to wait for the power state @@ -857,38 +814,43 @@ class DecortController(object): NOP_STATES_FOR_POWER_CHANGE = ["MIGRATING", "DELETED", "DESTROYING", "DESTROYED", "ERROR"] - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_powerstate") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_powerstate") if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = ("vm_powerstate() in check mode. Power state change of VM ID {} " - "to '{}' was requested.").format(arg_vm_dict['id'], arg_target_state) + self.result['msg'] = ("compute_powerstate() in check mode. Power state change of Compute ID {} " + "to '{}' was requested.").format(comp_facts['id'], target_state) return - if arg_vm_dict['status'] in NOP_STATES_FOR_POWER_CHANGE: + if comp_facts['status'] in NOP_STATES_FOR_POWER_CHANGE: self.result['failed'] = False - self.result['msg'] = ("vm_powerstate(): no power state change possible for VM ID {} " - "in current state '{}'.").format(arg_vm_dict['id'], arg_vm_dict['status']) + self.result['msg'] = ("compute_powerstate(): no power state change possible for Compute ID {} " + "in its current state '{}'.").format(comp_facts['id'], comp_facts['status']) return powerstate_api = "" # this string will also be used as a flag to indicate that API call is necessary - api_params = dict(machineId=arg_vm_dict['id']) - - if arg_vm_dict['status'] == "RUNNING": - if arg_target_state == 'paused': - powerstate_api = "/restmachine/cloudapi/machines/pause" - elif arg_target_state in ('poweredoff', 'halted'): - powerstate_api = "/restmachine/cloudapi/machines/stop" - api_params['force'] = force_change - elif arg_target_state == 'restarted': - powerstate_api = "/restmachine/cloudapi/machines/reboot" - elif arg_vm_dict['status'] == "PAUSED" and arg_target_state in ('poweredon', 'restarted'): - powerstate_api = "/restmachine/cloudapi/machines/resume" - elif arg_vm_dict['status'] == "HALTED" and arg_target_state in ('poweredon', 'restarted'): - powerstate_api = "/restmachine/cloudapi/machines/start" + api_params = dict(compId=comp_facts['id']) + expected_state = "" + + if comp_facts['status'] == "RUNNING": + if target_state == 'paused': + powerstate_api = "/restmachine/cloudapi/compute/pause" + expected_state = "PAUSED" + elif target_state in ('poweredoff', 'halted', 'stopped'): + powerstate_api = "/restmachine/cloudapi/compute/stop" + params['force'] = force_change + expected_state = "HALTED" + elif target_state == 'restarted': + powerstate_api = "/restmachine/cloudapi/compute/reboot" + expected_state = "RUNNING" + elif comp_facts['status'] == "PAUSED" and target_state in ('poweredon', 'restarted', 'started'): + powerstate_api = "/restmachine/cloudapi/compute/resume" + expected_state = "RUNNING" + elif comp_facts['status'] == "HALTED" and target_state in ('poweredon', 'restarted', 'started'): + powerstate_api = "/restmachine/cloudapi/compute/start" + expected_state = "RUNNING" else: - # VM seems to be in the desired power state already - do not call API + # This Compute instance seems to be in the desired power state already - do not call API pass if powerstate_api != "": @@ -896,117 +858,238 @@ class DecortController(object): # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True + comp_facts['status'] = expected_state else: self.result['failed'] = False - self.result['msg'] = ("vm_powerstate(): no power state change required for VM ID {} its from current " - "state '{}' to desired state '{}'.").format(arg_vm_dict['id'], - arg_vm_dict['status'], - arg_target_state) + self.result['warning'] = ("compute_powerstate(): no power state change required for Compute ID {} from its " + "current state '{}' to desired state '{}'.").format(comp_facts['id'], + comp_facts['status'], + target_state) return - def vm_provision(self, arg_rg_id, arg_vm_name, - arg_cpu, arg_ram, - arg_boot_disk, arg_image_id, - arg_data_disks=None, - arg_annotation="", - arg_userdata=None, - arg_start_vm=True): - """Manage VM provisioning. - To remove VM use vm_remove method. - To resize VM use vm_size, to manage VM power state use vm_powerstate method. - - @param arg_rg_id: integer ID of the RG where the VM will be provisioned. - @param arg_vm_name: string that specifies the name of the VM. - @param arg_cpu: integer count of virtual CPUs to allocate. - @param arg_ram: integer volume of RAM to allocate, specified in MB (i.e. pass 4096 to allocate 4GB RAM). - @param arg_boot_disk: dictionary with boot disk specifications. - @param arg_image_id: integer ID of the OS image to be deployed on the VM. - @param arg_data_disks: list of additional data disk sizes in GB. Pass empty list if no data disks needed. - @param arg_annotation: string that specified the description for the VM. - @param arg_userdata: additional paramters to pass to cloud-init facility of the guest OS. - @param arg_start_vm: set to False if you want the VM to be provisioned in HALTED state (requires DECORT API - version 3.3.1 or higher). - - @return ret_vm_id: integer value that specifies the VM ID of provisioned VM. In check mode it will return 0. + def kvmvm_provision(self, rg_id, + comp_name, arch, + cpu, ram, + boot_disk, image_id, + annotation="", + userdata=None, + start_on_create=True): + """Manage KVM VM provisioning. To remove existing KVM VM compute instance use compute_remove method, + to resize use compute_resize, to manage power state use compute_powerstate method. + + @param (int) rg_id: ID of the RG where the VM will be provisioned. + @param (string) comp_name: that specifies the name of the VM. + @param (string) arch: hardware architecture of KVM VM. Supported values are: "KVM_X86" for Intel x86 + and "KVM_PPC" for IBM PowerPC. + @param (int) cpu: how many virtual CPUs to allocate. + @param (int) ram: volume of RAM in MB to allocate (i.e. pass 4096 to allocate 4GB RAM). + @param (int) boot_disk: boot disk size in GB. + @param (int) image_id: ID of the OS image to base this Compute on. + @param (string) annotation: optional text description for the VM. + @param (string) userdata: additional paramters to pass to cloud-init facility of the guest OS. + @param (bool) start_on_create: set to False if you want the VM to be provisioned in HALTED state. + + @return (int) ret_kvmvm_id: ID of provisioned VM. + Note: when Ansible is run in check mode method will return 0. """ - # - # TODO - add support for different types of boot & data disks - # Currently type attribute of boot & data disk specifications are ignored until new storage provider types - # are implemented into the cloud platform. - # - - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_provision") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "kvmvm_provision") if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = ("vm_provision() in check mode. Provision VM '{}' in VDC ID {} " - "was requested.").format(arg_vm_name, arg_rg_id) + self.result['msg'] = ("kvmvm_provision() in check mode. Provision KVM VM '{}' in RG ID {} " + "was requested.").format(comp_name, rg_id) return 0 - # Extract data disk parameters in case the list was supplied in arg_data_disks argument - data_disk_sizes = [] - if arg_data_disks: - for ddisk in arg_data_disks: - # TODO - as new storage resource providers are added, this algorithms will be reworked - data_disk_sizes.append(ddisk['size']) + api_url="" + if arch == "KVM_X86": + api_url = "/restmachine/cloudapi/kvmx86/create" + elif arch == "KVM_PPC": + api_url = "/restmachine/cloudapi/kvmppc/create" + else: + self.result['failed'] = True + self.result['msg'] = "Unsupported architecture '{}' requested for KVM VM name '{}'".format(arch, comp_name) + self.amodule.fail_json(**self.result) + # fail the module - exit + + api_params = dict(rgId=rg_id, + name=comp_name, + description=annotation, + vcpus=cpu, memory=ram, + imageId=image_id, + disksize=boot_disk, + start_machine=start_on_create) # start_machine parameter requires DECORT API ver 3.3.1 or higher + if userdata: + api_params['userdata'] = json.dumps(userdata) # we need to pass a string object as "userdata" - api_params = dict(rgId=arg_rg_id, - name=arg_vm_name, - description=arg_annotation, - vcpus=arg_cpu, memory=arg_ram, - imageId=arg_image_id, - disksize=arg_boot_disk['size'], - datadisks=data_disk_sizes, - start_machine=arg_start_vm) # this parameter requires DECORT API ver 3.3.1 or higher - if arg_userdata: - api_params['userdata'] = json.dumps(arg_userdata) # we need to pass a string object as "userdata" - - api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/create", api_params) + api_resp = self.decort_api_call(requests.post, api_url, api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True ret_vm_id = int(api_resp.content) + return ret_vm_id - def vm_resize_vector(self, arg_vm_dict, arg_cpu, arg_ram): - """Check if the VM size parameters passed to this function are different from the current VM configuration. - This method is intended to be called to see if the VM would be resized in the course of module run, as - sometimes resizing may happen implicitly (e.g. when state = present and the specified size is different - from the current configuration of per-existing target VM. + def compute_networks(self, comp_dict, new_networks): + """Manage network configuration of Compute instance. + + @param (dict) comp_dict: dictionary, which identifies this Compute instance, formatted + as returned by previous call to compute_find(...). + @param (list of dicts) new_networks: list of dictionaries with network specs, which defines + new network configuration for the Compute. Keys in the network specification dictionary: + (string) type - one of "VINS" or "EXTNET", to connect to either private or public + network respectively. + (int) id - ID of the ViNS or external network, interpreted based on net_type value. + (string) ip_addr - optional IP address to assign to this connection. If empty string is + specified, the platform will assign the address automatically. + + Note: as this method may change network configuration of the compute instance, comp_dict + will no longer contain actual info about interfaces. It is recommended that compute + facts are updated in the upstream code. + """ + + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_networks") + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = ("compute_networks() in check mode: network reconfig for Compute " + "ID {} was requested.").format(comp_dict['id']) + return + + api_params = dict(accountId = comp_dict['accountId']) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/vins/search", api_params) + vins_list = json.loads(api_resp.content.decode('utf8')) + if not len(vins_list): + self.result['failed'] = True + self.result['msg'] = ("compute_networks() cannot obtain VINS list for Account ID {}, " + "Compute ID {}.").format(comp_dict['accountId'], comp_dict['id']) + return + + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/externalnetworks/list", api_params) + extnet_list = json.loads(api_resp.content.decode('utf8')) # list of dicts: "name" holds "NET_ADDR/NETMASK", "id" is ID + if not len(vins_list): + self.result['failed'] = True + self.result['msg'] = ("compute_networks() cannot obtain External networks list for Account ID {}, " + "Compute ID {}.").format(comp_dict['accountId'], comp_dict['id']) + return + + vins_iface_list = [] # will contain dict(id=, ipAddress=, mac=) for ifaces connected to ViNS(es) + enet_iface_list = [] # will contain dict(id=, ipAddress=, mac=) for ifaces connected to Ext net(s) + for iface in comp_dict['interfaces']: + if iface['connType'] == 'VXLAN': + for vrunner in vins_list: + if vrunner['vxlanId'] == iface['connId']: + iface_data = dict(id=vrunner['id'], + ipAddress=iface['ipAddress'], + mac=iface['mac']) + vins_iface_list.append(iface_data) + elif iface['connType'] == 'VLAN': + ip_addr = netaddr.IPAddress(iface['ipAddress']) + for erunner in extnet_list: + # match by IP address range + # if iface['ipAddress'] <-> erunner['name'] + # enet_iface_list.append(erunner['id']) + ip_extnet = netaddr.IPNetwork(erunner['ip']) + if ip_addr.value < ip_extnet.first or ip_addr.value > ip_extnet.last: + # out of net range + continue + else: + iface_data = dict(id=erunner['id'], + ipAddress=iface['ipAddress'], + mac=iface['mac']) + enet_iface_list.append(iface_data) + + vins_id_list = [ rec['id'] for rec in vins_iface_list ] + enet_id_list = [ rec['id'] for rec in enet_iface_list ] + + # Build attach list by looking for ViNS/Ext net IDs that appear in new_networks, but do not appear in current lists + attach_list = [] # attach list holds both ViNS and Ext Net attachment specs, as these do not differ from API perspective + for netrunner in new_networks: + if netrunner['type'] == 'VINS' and netrunner['id'] not in vins_id_list: + net2attach = dict(computeId=comp_dict['id'], + netType='VINS', + netId=netrunner['id'], + ipAddr=netrunner.get('ip_addr', "")) + attach_list.append(net2attach) + elif netrunner['type'] == 'EXTNET' and netrunner['id'] not in enet_id_list: + net2attach = dict(computeId=comp_dict['id'], + netType='EXTNET', + netId=netrunner['id'], + ipAddr=netrunner.get('ip_addr', "")) + attach_list.append(net2attach) + + # Build detach list by looking for ViNS/Ext net IDs that appear in current lists, but do not appear in new_networks + detach_list = [] # detach list holds both ViNS and Ext Net detachment specs, as these do not differ from API perspective + target_list = [ rec['id'] for rec in new_networks if rec['type'] == 'VINS' ] + for netrunner in vins_iface_list: + if netrunner['id'] not in target_list: + net2detach = dict(computeId=comp_dict['id'], + ipAddr=netrunner['ipAddress'], + mac=netrunner['mac']) + detach_list.append(net2detach) + + target_list = [ rec['id'] for rec in new_networks if rec['type'] == 'EXTNET' ] + for netrunner in enet_iface_list: + if netrunner['id'] not in target_list: + net2detach = dict(computeId=comp_dict['id'], + ipAddr=netrunner['ipAddress'], + mac=netrunner['mac']) + detach_list.append(net2detach) + + + for api_params in detach_list: + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/netDetach", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. - @param arg_vm_dict: dictionary of the VM parameters as returned by previous call to vm_facts. - @param arg_cpu: requested (aka new) CPU count. - @param arg_ram: requested RAM size in MBs. + for api_params in attach_list: + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/netAttach", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. - @return: VM_RESIZE_NOT if no change required, VM_RESIZE_DOWN if sizing VM down (this will required VM to be in - one of the stopped states), VM_RESIZE_UP if sizing VM up (no guest OS restart is generally required for most - of the modern OS). + self.result['failed'] = False + self.result['changed'] = True + + return + + def compute_resize_vector(self, comp_dict, new_cpu, new_ram): + """Check if the Compute new size parameters passed to this function are different from the + current Compute configuration. + This method is intended to be called to see if the Compute would be resized in the course + of module run, as sometimes resizing may happen implicitly (e.g. when state = present and + the specified size is different from the current configuration of per-existing target VM. + + @param (dict) comp_dict: dictionary of the Compute parameters as returned by previous call + to compute_find(...). + @param (int) new_cpu: new CPU count. + @param (int) new_ram: new RAM size in MBs. + + @return: VM_RESIZE_NOT if no change required, VM_RESIZE_DOWN if sizing down (this will + require Compute to be in one of the stopped states), VM_RESIZE_UP if sizing Compute up + (no guest OS restart is generally required for the majority of modern OS-es). """ # NOTE: This method may eventually be deemed as redundant and as such may be removed. - if arg_vm_dict['vcpus'] == arg_cpu and arg_vm_dict['memory'] == arg_ram: + if comp_dict['cpus'] == new_cpu and comp_dict['ram'] == new_ram: return DecortController.VM_RESIZE_NOT - if arg_vm_dict['vcpus'] < arg_cpu or arg_vm_dict['memory'] < arg_ram: + if comp_dict['cpus'] < new_cpu or comp_dict['ram'] < new_ram: return DecortController.VM_RESIZE_UP - if arg_vm_dict['vcpus'] > arg_cpu or arg_vm_dict['memory'] > arg_ram: + if comp_dict['cpus'] > new_cpu or comp_dict['ram'] > new_ram: return DecortController.VM_RESIZE_DOWN return DecortController.VM_RESIZE_NOT - def vm_resource_check(self): - """Check available resources (in case limits are set on the target VDC and/or account) to make sure that - the requested VM can be deployed. + def compute_resource_check(self): + """Check available resources (in case limits are set on the target VDC and/or account) to make sure + that this Compute instance can be deployed. @return: True if enough resources, False otherwise. - @return: Dictionary of remaining resources estimation after the specified VM would have been deployed. + @return: Dictionary of remaining resources estimation after the specified Compute instance would + have been deployed. """ - # self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_resource_check") + # self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_resource_check") # # TODO - This method is under construction @@ -1014,37 +1097,37 @@ class DecortController(object): return - def vm_restore(self, arg_vm_id): - """Restores a deleted VM identified by VM ID. + def compute_restore(self, comp_id): + """Restores a deleted Compute instance identified by ID. - @param arg_vm_id: integer value that defines the ID of a VM to be restored. + @param compid: ID of the Compute to restore. """ - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_restore") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_restore") if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = "vm_restore() in check mode: restore VM ID {} was requested.".format(arg_vm_id) + self.result['msg'] = "compute_restore() in check mode: restore Compute ID {} was requested.".format(comp_id) return - api_params = dict(machineId=arg_vm_id, + api_params = dict(computeId=comp_id, reason="Restored on user {} request by Ansible DECORT module.".format(self.decort_username)) - self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/restore", api_params) + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/restore", api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True return - def vm_size(self, arg_vm_dict, arg_cpu, arg_ram, wait_for_state_change=0): + def compute_resize(self, comp_dict, new_cpu, new_ram, wait_for_state_change=0): """Resize existing VM. - @param arg_vm_dict: dictionary with the current specification of the VM to be resized. - @param arg_cpu: integer new vCPU count. - @param arg_ram: integer new RAM size in GB. - @param wait_for_state_change: integer number that tells how many 5 seconds intervals to wait for VM power state to - change so that the resize operation can be carried out. Set this to non zero value if you expect that - the state of VM will change shortly (usually, when you call this method after vm_powerstate(...)) + @param (dict) comp_dict: dictionary with the facts about the Compute to resize. + @param (int) new_cpu: new vCPU count to set. + @param (int) ram: new RAM size in MB. + @param (int) wait_for_state_change: integer number that tells how many 5 seconds intervals to wait + for the Compute instance power state to change so that the resize operation can be carried out. Set + this to non zero value if you expect that the state of Compute will change shortly (usually, when + you call this method after compute_powerstate(...)) """ # @@ -1054,102 +1137,111 @@ class DecortController(object): INVALID_STATES_FOR_HOT_DOWNSIZE = ["RUNNING", "MIGRATING", "DELETED"] - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_size") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_resize") + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = ("compute_resize() in check mode: resize of Compute ID {} from CPU:RAM {}:{} to {}:{} " + "was requested.").format(comp_dict['id'], + comp_dict['cpus'], comp_dict['ram'], + new_cpu, new_ram) + return # We need to handle a situation when either of 'cpu' or 'ram' parameter was not supplied. This is acceptable # when we manage state of the VM or request change to only one parameter - cpu or ram. # In such a case take the "missing" value from the current configuration of the VM. - if not arg_cpu and not arg_ram: + if not new_cpu and not new_ram: # if both are 0 or Null - return immediately, as user did not mean to manage size self.result['failed'] = False + self.result['warning'] =("compute_resize: new CPU count and RAM size are both zero for Compute ID {}" + " - nothing to do.").format(comp_dict['id']) return - if not arg_cpu: - arg_cpu = arg_vm_dict['vcpus'] - elif not arg_ram: - arg_ram = arg_vm_dict['memory'] + if not new_cpu: + new_cpu = comp_dict['cpus'] + elif not new_ram: + new_ram = comp_dict['ram'] # stupid hack? - if arg_ram > 1 and arg_ram < 512: - arg_ram = arg_ram*1024 - - if self.amodule.check_mode: - self.result['failed'] = False - self.result['changed'] = False - self.result['msg'] = ("vm_size() in check mode: resize of VM ID {} from CPU:RAM {}:{} to {}:{} requested." - "was requested.").format(arg_vm_dict['id'], - arg_vm_dict['vcpus'], arg_vm_dict['memory'], - arg_cpu, arg_ram) - return + if new_ram > 1 and new_ram < 512: + new_ram = new_ram*1024 - if arg_vm_dict['vcpus'] == arg_cpu and arg_vm_dict['memory'] == arg_ram: + if comp_dict['cpus'] == new_cpu and comp_dict['ram'] == new_ram: # no need to call API in this case, as requested size is not different from the current one self.result['failed'] = False + self.result['warning'] =("compute_resize: new CPU count and RAM size are the same for Compute ID {}" + " - nothing to do.").format(comp_dict['id']) return - if ((arg_vm_dict['vcpus'] > arg_cpu or arg_vm_dict['memory'] > arg_ram) and - arg_vm_dict['status'] in INVALID_STATES_FOR_HOT_DOWNSIZE): + if ((comp_dict['cpus'] > new_cpu or comp_dict['memory'] > new_ram) and + comp_dict['status'] in INVALID_STATES_FOR_HOT_DOWNSIZE): while wait_for_state_change: time.sleep(5) - fresh_vm_dict = self.vm_facts(arg_vm_id=arg_vm_dict['id']) - if fresh_vm_dict['status'] not in INVALID_STATES_FOR_HOT_DOWNSIZE: + fresh_comp_dict = self.compute_find(arg_vm_id=comp_dict['id']) + comp_dict['status'] = fresh_comp_dict['status'] + if fresh_comp_dict['status'] not in INVALID_STATES_FOR_HOT_DOWNSIZE: break wait_for_state_change = wait_for_state_change - 1 if not wait_for_state_change: self.result['failed'] = True - self.result['msg'] = ("vm_size() downsize of VM ID {} from CPU:RAM {}:{} to {}:{} was requested, " - "but VM is in the state '{}' incompatible with down size operation").\ - format(arg_vm_dict['id'], - arg_vm_dict['vcpus'], arg_vm_dict['memory'], - arg_cpu, arg_ram, arg_vm_dict['status']) + self.result['msg'] = ("compute_resize(): downsize of Compute ID {} from CPU:RAM {}:{} to {}:{} was " + "requested, but its current state '{}' is incompatible with downsize operation.").\ + format(comp_dict['id'], + comp_dict['cpus'], comp_dict['ram'], + new_cpu, new_ram, comp_dict['status']) return - api_resize_params = dict(machineId=arg_vm_dict['id'], - memory=arg_ram, - vcpus=arg_cpu,) - self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/resize", api_resize_params) + api_params = dict(computeId=comp_dict['id'], + ram=new_ram, + cpu=new_cpu,) + self.decort_api_call(requests.post, "/restmachine/cloudapi/compute/resize", api_resize_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True + comp_dict['cpus'] = new_cpu + comp_dict['ram'] - new_ram return - def vm_wait4state(self, arg_vm_id, arg_state, arg_check_num=6, arg_sleep=5): - """Helper method to wait for the VM to enter the specified state. Intended usage of this method - is check if VM is already in HALTED state after calling vm_powerstate(...), as vm_powerstate() may - return before VM actually enters the target state. + def compute_wait4state(self, comp_id, pwstate, num_checks=6, sleep_time=5): + """Helper method to wait for the Compute instance to enter the specified state. Intended + usage of this method is check if specified Compute is already in HALTED state after + calling compute_powerstate(...), as compute_powerstate() may return before Compute instance + actually enters the target state. - @param arg_vm_id: ID of the VM. - @param arg_state: target powerstate of the VM. Make sure that you specify valid state, or the method - will return immediately with False. - @param arg_check_num: how many check attempts to take before giving up and returning False. - @param arg_sleep: sleep time in seconds between checks. + @param (int) comp_id: ID of the Compute to monitor. + @param (string) pwstate: target powerstate of the Compute. Make sure that you specify valid + state, or the method will return immediately with False. + @param num_checks: how many check attempts to take before giving up and returning False. + @param sleep_time: sleep time in seconds between checks. - @return: True if the target state is detected within the specified number of checks. False otherwise or - if an invalid target state is specified. + @return: True if the target state is detected within the specified number of checks. False + otherwise or if an invalid target state is specified. Note: this method will abort module execution if no target VM is found. """ - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "vm_check4state") + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "compute_wait4state") - vm_id, vm_info, _ = self.vm_find(arg_vm_id) - if not vm_id: + validated_comp_id, comp_dict, _ = self.compute_find(comp_id) + if not validated_comp_id: self.result['failed'] = True - self.result['changed'] = False - self.result['msg'] = "Cannot find the specified VM ID {}".format(arg_vm_id) + self.result['msg'] = "Cannot find the specified Compute ID {}".format(comp_id) self.amodule.fail_json(**self.result) - if arg_state not in ('RUNNING', 'PAUSED', 'HALTED', 'DELETED', 'DESTROYED'): - self.result['msg'] = "vm_wait4state: invalid target state '{}' specified.".format(arg_state) + if pwstate not in ['RUNNING', 'PAUSED', 'HALTED', 'DELETED', 'DESTROYED']: + self.result['warning'] = "compute_wait4state: invalid target state '{}' specified.".format(pwstate) return False - if vm_info['status'] == arg_state: + if comp_dict['status'] == pwstate: return True - for _ in range(0, arg_check_num): - time.sleep(arg_sleep) - _, vm_info, _ = self.vm_find(arg_vm_id) - if vm_info['status'] == arg_state: + if sleep_time < 1: + sleep_time = 1 + + for _ in range(0, num_checks): + time.sleep(sleep_time) + _, comp_dict, _ = self.compute_find(comp_id) + if comp_dict['status'] == pwstate: return True return False @@ -1157,68 +1249,92 @@ class DecortController(object): ################################### # OS image manipulation methods ################################### - def image_find(self, arg_osimage_name, arg_rg_id, arg_account_id=0, arg_sepid=0, arg_pool=""): + def _image_get_by_id(self, image_id): + # TODO: update once cloudapi/images/get is implemented, see ticket #2963 + + api_params = dict(imageId=image_id, + showAll=False) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/images/get", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + ret_image_dict = json.loads(api_resp.content.decode('utf8')) + + return image_id, ret_image_dict + + + def image_find(self, image_id, image_name, account_id, rg_id=0, sepid=0, pool=""): """Locates image specified by name and returns its facts as dictionary. Primary use of this function is to obtain the ID of the image identified by its name and, optionally SEP ID and/or pool name. Also note that only images in status CREATED are returned. - @param (string) arg_os_image: string that contains the name of the OS image - @param (int) arg_rg_id: ID of the RG to use as a reference when listing OS images - @param (int) arg_account_id: ID of the account for which the image will be looked up. If set to 0, the account ID - will be obtained from the specified VDC's facts - @param (int) arg_sepid: ID of the SEP where the image should be present. If set to 0, there will be no + @param (string) image_id: ID of the OS image to find. If non-zero ID is specified, then + image_name is ignored. + @param (string) image_name: name of the OS image to find. This argument is ignored if non-zero + image ID is passed. + @param (int) account_id: ID of the account for which the image will be looked up. If set to 0, + the account ID will be obtained from the specified RG ID. + @param (int) rg_id: ID of the RG to use as a reference when listing OS images. This argument is + ignored if non-zero image id and/or non-zero account_id are specified. + @param (int) sepid: ID of the SEP where the image should be present. If set to 0, there will be no filtering by SEP ID and the first matching image will be returned. - @param (string) arg_pool: name of the pool where the image should be present. If set to empty string, there + @param (string) pool: name of the pool where the image should be present. If set to empty string, there will be no filtering by pool name and first matching image will be returned. - @return: dictionary with image specs. If no image found by the specified name, it returns emtpy dictionary - and sets self.result['failed']=True. + @return: image ID and dictionary with image specs. If no matching image found, 0 for ID and None for + dictionary are returned, and self.result['failed']=True. """ self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "image_find") - if arg_account_id == 0: - _, rg_facts = self._rg_get_by_id(arg_rg_id) - arg_account_id = rg_facts['accountId'] - - # api_params = dict(rgId=arg_rg_id) - api_params = dict(accountId=arg_account_id) + if image_id > 0: + ret_image_id, ret_image_dict = self._image_get_by_id(image_id) + if ( ret_image_id and + (sepid == 0 or sepid == ret_image_dict['sepid']) and + (pool == "" or pool == ret_image_dict['pool']) ): + return ret_image_id, ret_image_dict + else: + validated_acc_id = account_id + if account_id == 0: + validated_rg_id, rg_facts = self._rg_get_by_id(rg_id) + if not validated_rg_id: + self.result['failed'] = True + self.result['msg'] = ("Failed to find RG ID {}, and account ID is zero.").format(rg_id) + return 0, None + validated_acc_id = rg_facts['accountId'] - api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/images/list", api_params) - # On success the above call will return here. On error it will abort execution by calling fail_json. - image_list = json.loads(api_resp.content.decode('utf8')) - for image_record in image_list: - if image_record['name'] == arg_osimage_name and image_record['status'] == "CREATED": - if arg_sepid == 0 and arg_pool == "": - # if no filtering by SEP ID or pool name is requested, return the first match - return image_record - # if positive SEP ID and/or non-emtpy pool name are passed, try to match by them - full_match = True - if arg_sepid > 0 and arg_sepid != image_record['sepid']: - full_match = False - if arg_pool != "" and arg_pool != image_record['pool']: - full_match = False - if full_match: - return image_record + api_params = dict(accountId=validated_acc_id) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/images/list", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + images_list = json.loads(api_resp.content.decode('utf8')) + for image_record in images_list: + if image_record['name'] == image_name and image_record['status'] == "CREATED": + if sepid == 0 and pool == "": + # if no filtering by SEP ID or pool name is requested, return the first match + return image_record['id'], image_record + # if positive SEP ID and/or non-emtpy pool name are passed, match by them + full_match = True + if sepid > 0 and sepid != image_record['sepid']: + full_match = False + if pool != "" and pool != image_record['pool']: + full_match = False + if full_match: + return image_record['id'], image_record self.result['failed'] = True self.result['msg'] = ("Failed to find OS image by name '{}', SEP ID {}, pool '{}' for " - "account ID '{}'.").format(arg_osimage_name, - arg_sepid, arg_pool, - arg_account_id) - return None + "account ID '{}'.").format(image_name, + sepid, pool, + account_id) + return 0, None ################################### - # VDC (cloud space) resource manipulation methods - ################################### - # TODO: the below methods will require rework once we abandon VDC in favour of a more advanced concept + # Resource Group (RG) manipulation methods ################################### - def rg_delete(self, arg_rg_id, arg_permanently=False): + def rg_delete(self, rg_id, permanently=False): """Deletes specified VDC. - @param arg_rg_id: integer value that identifies the RG to be deleted. - @param arg_permanently: a bool that tells if deletion should be permanent. If False, the RG will be + @param (int) rg_id: integer value that identifies the RG to be deleted. + @param (bool) permanently: a bool that tells if deletion should be permanent. If False, the RG will be marked as deleted and placed into a trash bin for predefined period of time (usually, a few days). Until this period passes the RG can be restored by calling the corresponding 'restore' method. """ @@ -1227,7 +1343,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = "rg_delete() in check mode: delete RG ID {} was requested.".format(arg_rg_id) return @@ -1235,9 +1350,9 @@ class DecortController(object): # TODO: need decision if deleting a VDC with VMs in it is allowed (aka force=True) and implement accordingly. # - api_params = dict(rgId=arg_rg_id, + api_params = dict(rgId=rg_id, # force=True | False, - permanently=arg_permanently,) + permanently=permanently,) self.decort_api_call(requests.post, "/restmachine/cloudapi/rg/delete", api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False @@ -1245,10 +1360,10 @@ class DecortController(object): return - def _rg_get_by_id(self, arg_rg_id): + def _rg_get_by_id(self, rg_id): """Helper function that locates RG by ID and returns RG facts. - @param arg_rg_id: ID of the RG to find and return facts for. + @param (int) )rg_id: ID of the RG to find and return facts for. @return: RG ID and a dictionary of RG facts as provided by rg/get API call. Note that if it fails to find the RG with the specified ID, it may return 0 for ID and empty dictionary for the facts. So @@ -1257,19 +1372,19 @@ class DecortController(object): ret_rg_id = 0 ret_rg_dict = dict() - if not arg_rg_id: + if not rg_id: self.result['failed'] = True self.result['msg'] = "rg_get_by_id(): zero RG ID specified." self.amodule.fail_json(**self.result) - api_params = dict(rgId=arg_rg_id,) + api_params = dict(rgId=rg_id,) api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/rg/get", api_params) if api_resp.status_code == 200: - ret_rg_id = arg_rg_id + ret_rg_id = rg_id ret_rg_dict = json.loads(api_resp.content.decode('utf8')) else: self.result['warning'] = ("rg_get_by_id(): failed to get RG by ID {}. HTTP code {}, " - "response {}.").format(arg_rg_id, api_resp.status_code, api_resp.reason) + "response {}.").format(rg_id, api_resp.status_code, api_resp.reason) return ret_rg_id, ret_rg_dict @@ -1347,23 +1462,7 @@ class DecortController(object): return ret_rg_id, ret_rg_dict - def rg_portforwards(self): - """Manage port forwarding rules at the VDC level""" - # - # TODO - not implemented yet - need use case for this method to decide if it should be implemented at all - # - - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "rg_portforwards") - - if self.amodule.check_mode: - self.result['failed'] = False - self.result['changed'] = False - # self.result['msg'] = ("rg_portforwards() in check mode: port forwards configuration for VDC name '{}' " - # "was requested.").format(arg_rg_name) - return - - return - + def rg_provision(self, arg_account_id, arg_rg_name, arg_username, arg_quota={}, arg_location="", arg_desc=""): """Provision new RG according to the specified arguments. If critical error occurs the embedded call to API function will abort further execution of the script @@ -1388,7 +1487,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = ("rg_provision() in check mode: provision RG name '{}' was " "requested.").format(arg_rg_name) return 0 @@ -1428,6 +1526,7 @@ class DecortController(object): ret_rg_id = int(api_resp.content.decode('utf8')) return ret_rg_id + # TODO: this method will not work in its current implementation. Update it for new .../rg/update specs. def rg_quotas(self, arg_rg_dict, arg_quotas): """Manage quotas for an existing RG. @@ -1446,7 +1545,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = ("rg_quotas() in check mode: setting quotas on RG ID {}, RG name '{}' was " "requested.").format(arg_rg_dict['id'], arg_rg_dict['name']) return @@ -1483,7 +1581,7 @@ class DecortController(object): api_params[set_key_map[new_limit]] = -1 if quota_change_required: - self.decort_api_call(requests.post, "/restmachine/cloudapi/cloudspaces/update", api_params) + self.decort_api_call(requests.post, "/restmachine/cloudapi/rg/update", api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True @@ -1501,7 +1599,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = "rg_restore() in check mode: restore RG ID {} was requested.".format(arg_rg_id) return @@ -1541,7 +1638,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = ("rg_state() in check mode: setting state of RG ID {}, name '{}' to " "'{}' was requested.").format(arg_rg_dict['id'], arg_rg_dict['name'], arg_desired_state) @@ -1550,17 +1646,21 @@ class DecortController(object): rgstate_api = "" # this string will also be used as a flag to indicate that API call is necessary api_params = dict(rgId=arg_rg_dict['id'], reason='Changed by DECORT Ansible module, rg_state method.') + expected_state = "" if arg_rg_dict['status'] in ["CREATED", "ENABLED"] and arg_desired_state == 'disabled': rgstate_api = "/restmachine/cloudapi/rg/disable" + expected_state = "DISABLED" elif arg_rg_dict['status'] == "DISABLED" and arg_desired_state == 'enabled': rgstate_api = "/restmachine/cloudapi/rg/enable" + expected_state = "ENABLED" if rgstate_api != "": self.decort_api_call(requests.post, rgstate_api, api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True + arg_rg_dict['status'] = expected_state else: self.result['failed'] = False self.result['msg'] = ("rg_state(): no state change required for RG ID {} from current " @@ -1569,40 +1669,13 @@ class DecortController(object): arg_desired_state) return - def rg_vms_list(self, arg_rg_id=0, arg_rg_name=""): - """List virtual machines in the specified RG. - VDC can be identified either by its ID or by a combination of RG name and account name. - - @param arg_rg_id: ID the VDC to list VMs from. - @param arg_rg_name: name of the VDC to list VMs from. - - @returns: dictionary of VMs from the specified VDC. Please note that it may return an emtpy dictionary - if no VMs are currently present in the VDC. - """ - - self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "rg_vms_list") - - rg_id, _ = self.rg_find(arg_rg_id, arg_rg_name) - - if not rg_id: - self.result['failed'] = True - self.result['msg'] = "vm_vms_list(): cannot find VDC by ID {} or name '{}'".format(arg_rg_id, arg_rg_name) - - api_params = dict(rgId=rg_id) - api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/machines/list", api_params) - if api_resp.status_code == 200: - vms_list = json.loads(api_resp.content.decode('utf8')) - - return vms_list - - - def account_find(self, arg_account_name, arg_account_id=0): + def account_find(self, account_name, account_id=0): """Find cloud account specified by the name and return facts about the account. Knowing account is required for certain cloud resource management tasks (e.g. creating new RG). - @param (string) arg_account_name: name of the account to find. Pass empty string if you want to + @param (string) account_name: name of the account to find. Pass empty string if you want to find account by ID and make sure you specifit posisite arg_account_id. - @param (int) arg_ccount_id: ID of the account to find. If arg_account_name is empty string, then + @param (int) ccount_id: ID of the account to find. If arg_account_name is empty string, then arg_account_id must be positive, and method will look for account by the specified ID. Returns non zero account ID and a dictionary with account details on success, 0 and empty @@ -1611,15 +1684,15 @@ class DecortController(object): self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "account_find") - if arg_account_name == "" and arg_account_id == 0: + if account_name == "" and account_id == 0: self.result['failed'] = True self.result['msg'] = "Cannot find account if account name is empty and account ID is zero." self.amodule.fail_json(**self.result) api_params = dict() - if arg_account_name == "": - api_params['accountId'] = arg_account_id + if account_name == "": + api_params['accountId'] = account_id api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/accounts/get", api_params) if api_resp.status_code == 200: account_details = json.loads(api_resp.content.decode('utf8')) @@ -1631,7 +1704,7 @@ class DecortController(object): # If it is found, assign its ID to the return variable and copy dictionary with the facts accounts_list = json.loads(api_resp.content.decode('utf8')) for runner in accounts_list: - if runner['name'] == arg_account_name: + if runner['name'] == account_name: # get detailed information about the account from "accounts/get" call as # "accounts/list" does not return all necessary fields api_params['accountId'] = runner['id'] @@ -1798,10 +1871,11 @@ class DecortController(object): return ret_gid + ############################## # # ViNS management # - + ############################## def vins_delete(self, vins_id, permanently=False): """Deletes specified ViNS. @@ -1815,7 +1889,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = "vins_delete() in check mode: delete ViNS ID {} was requested.".format(vins_id) return @@ -1904,14 +1977,14 @@ class DecortController(object): else: return 0, None elif vins_name != "": - if account_id > 0: - # ignore rg_id and search for ViNS at account level - validated_id, validated_facts = self.account_find("", account_id) + if rg_id > 0: + # search for ViNS at RG level + validated_id, validated_facts = self._rg_get_by_id(rg_id) if not validated_id: self.result['failed'] = True - self.result['msg'] = "vins_find(): cannot find Account ID {}.".format(account_id) + self.result['msg'] = "vins_find(): cannot find RG ID {}.".format(rg_id) self.amodule.fail_json(**self.result) - # TODO: account's 'vins' attribute does not list deleted or destroyed ViNSes! + # NOTE: RG's 'vins' attribute does not list destroyed ViNSes! for runner in validated_facts['vins']: # api_params['vinsId'] = runner ret_vins_id, ret_vins_facts = self._vins_get_by_id(runner) @@ -1920,14 +1993,14 @@ class DecortController(object): return ret_vins_id, ret_vins_facts else: return 0, None - elif rg_id > 0: - # search for ViNS at RG level - validated_id, validated_facts = self._rg_get_by_id(rg_id) + elif account_id > 0: + # search for ViNS at account level + validated_id, validated_facts = self.account_find("", account_id) if not validated_id: self.result['failed'] = True - self.result['msg'] = "vins_find(): cannot find RG ID {}.".format(rg_id) + self.result['msg'] = "vins_find(): cannot find Account ID {}.".format(account_id) self.amodule.fail_json(**self.result) - # TODO: RG's 'vins' attribute does not list deleted or destroyed ViNSes! + # NOTE: account's 'vins' attribute does not list destroyed ViNSes! for runner in validated_facts['vins']: # api_params['vinsId'] = runner ret_vins_id, ret_vins_facts = self._vins_get_by_id(runner) @@ -1943,17 +2016,17 @@ class DecortController(object): self.amodule.fail_json(**self.result) else: # ViNS ID is 0 and ViNS name is emtpy - fail the module self.result['failed'] = True - self.result['msg'] = "vins_find(): cannot find ViNS with zero ID and empty name." + self.result['msg'] = "vins_find(): cannot find ViNS by zero ID and empty name." self.amodule.fail_json(**self.result) return 0, None def vins_provision(self, vins_name, account_id, rg_id=0, ipcidr="", ext_net_id=-1, ext_ip_addr="", desc=""): """Provision ViNS according to the specified arguments. - If critical error occurs the embedded call to API function will abort further execution of the script - and relay error to Ansible. - Note, that when creating ViNS at account level, default location under DECORT controller will be - selected automatically. + If critical error occurs the embedded call to API function will abort further execution of + the script and relay error to Ansible. + Note, that when creating ViNS at account level, default location under DECORT controller + will be selected automatically. @param (int) account_id: ID of the account where ViNS will be created. To create ViNS at account level specify non-zero account ID and zero RG ID. @@ -1976,7 +2049,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = ("vins_provision() in check mode: provision ViNS name '{}' was " "requested.").format(vins_name) return 0 @@ -2039,7 +2111,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = "vins_restore() in check mode: restore ViNS ID {} was requested.".format(vins_id) return @@ -2079,7 +2150,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = ("vins_state() in check mode: setting state of ViNS ID {}, name '{}' to " "'{}' was requested.").format(vins_dict['id'],vins_dict['name'], desired_state) @@ -2088,17 +2158,21 @@ class DecortController(object): vinsstate_api = "" # this string will also be used as a flag to indicate that API call is necessary api_params = dict(vinsId=vins_dict['id'], reason='Changed by DECORT Ansible module, vins_state method.') + expected_state = "" if vins_dict['status'] in ["CREATED", "ENABLED"] and desired_state == 'disabled': rgstate_api = "/restmachine/cloudapi/vins/disable" + expected_state = "DISABLED" elif vins_dict['status'] == "DISABLED" and desired_state == 'enabled': rgstate_api = "/restmachine/cloudapi/vins/enable" + expected_state = "ENABLED" if vinsstate_api != "": self.decort_api_call(requests.post, vinsstate_api, api_params) # On success the above call will return here. On error it will abort execution by calling fail_json. self.result['failed'] = False self.result['changed'] = True + vins_dict['status'] = expected_state else: self.result['failed'] = False self.result['msg'] = ("vins_state(): no state change required for ViNS ID {} from current " @@ -2118,6 +2192,9 @@ class DecortController(object): external network or positive network ID to connect to the specified external network. @param (string) ext_ip_addr: optional IP address to assign to the external network connection of this ViNS. + + Note: on success vins_dict may no longer contain actual info about this ViNS, so it is + recommended to update ViNS facts in the upstream code. """ api_params = dict(vinsId=vins_dict['id'],) @@ -2126,7 +2203,6 @@ class DecortController(object): if self.amodule.check_mode: self.result['failed'] = False - self.result['changed'] = False self.result['msg'] = ("vins_update() in check mode: updating ViNS ID {}, name '{}' " "was requested.").format(vins_dict['id'],vins_dict['name']) return @@ -2164,7 +2240,6 @@ class DecortController(object): self.result['failed'] = False # On success the above call will return here. On error it will abort execution by calling fail_json. else: - self.result['changed'] = False self.result['failed'] = False self.result['warning'] = ("vins_update(): ViNS ID {} is already connected to ext net ID {}, " "ignore ext IP address change if any.").format(vins_dict['id'], @@ -2177,7 +2252,6 @@ class DecortController(object): self.result['changed'] = True self.result['failed'] = False else: - self.result['changed'] = False self.result['failed'] = False self.result['warning'] = ("vins_update(): ViNS ID {} is already connected to ext net ID {}, " "no reconnection to default network will be done.").format(vins_dict['id'], @@ -2185,3 +2259,254 @@ class DecortController(object): return + ############################## + # + # Disk management + # + ############################## + + def disk_delete(self, disk_id, permanently=False, force_detach=False): + """Deletes specified Disk. + + @param (int) disk_id: ID of the Disk to be deleted. + @param (bool) arg_permanently: a bool that tells if deletion should be permanent. If False, the Disk will be + marked as DELETED and placed into a trash bin for predefined period of time (usually, a few days). Until + this period passes such a Disk can be restored by calling the corresponding 'restore' method. + """ + + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "disk_delete") + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = "disk_delete() in check mode: delete Disk ID {} was requested.".format(disk_id) + return + + api_params = dict(diskId=disk_id, + detach=force_detach, + permanently=permanently,) + self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/delete", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + self.result['failed'] = False + self.result['changed'] = True + return + + def _disk_get_by_id(self, disk_id): + """Helper function that locates Disk by ID and returns Disk facts. This function + expects that the Disk exists (albeit in DELETED or DESTROYED or PURGED state) and + will return zero ID if Disk is not found. + + @param (int) disk_id: ID of the disk to find and return facts for. + + @return: Disk ID and a dictionary of disk facts as provided by disks/get API call. + + Note that if it fails to find the Disk with the specified ID, it may return 0 for ID + and empty dictionary for the facts. So it is suggested to check the return values + accordingly in the upstream code. + """ + ret_disk_id = 0 + ret_disk_dict = dict() + + if not disk_id: + self.result['failed'] = True + self.result['msg'] = "disk_get_by_id(): zero Disk ID specified." + self.amodule.fail_json(**self.result) + + api_params = dict(diskId=disk_id,) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/get", api_params) + if api_resp.status_code == 200: + ret_disk_id = disk_id + ret_disk_dict = json.loads(api_resp.content.decode('utf8')) + else: + self.result['warning'] = ("disk_get_by_id(): failed to get Disk by ID {}. HTTP code {}, " + "response {}.").format(disk_id, api_resp.status_code, api_resp.reason) + + return ret_disk_id, ret_disk_dict + + # + # TODO: method is not fully implemented - need ../disks/list or ../disks/search API function! + # + def disk_find(self, disk_id, disk_name="", account_id=0, check_state=False): + """Find specified Disk. + + @param (int) disk_id: ID of the Disk. If non-zero disk_id is specified, all other arguments + are ignored, Disk must exist and is located by its ID only. + @param (string) disk_name: If disk_id is 0, then disk_name is mandatory, and the disk is looked + up in the specified account. + @param (int) account_id: id of the account that owns this disk. Ignored if non-zero disk_id is + specified, but becomes mandatory otherwise. + @param (bool) check_state: tells the method to report Disk(s) in valid states only. Set check_state + to False if you want to check if specified Disk exists at all without failing the module execution. + + @returns: Disk ID and dictionary with Disk facts. It may return zero ID and empty dictionary + if no Disk found and check_state=False, so make sure to check return values in the upstream + code accordingly. + """ + + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "disk_find") + + DISK_INVALID_STATES = ["MODELED", "CREATING", "DELETING", "DESTROYING"] + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = "disk_find() in check mode: find Disk ID {} / name '{}' was requested.".format(disk_id, disk_name) + return + + ret_disk_id = 0 + ret_disk_facts = None + + if disk_id > 0: + ret_disk_id, ret_disk_facts = self._disk_get_by_id(disk_id) + if not ret_disk_id: + self.result['failed'] = True + self.result['msg'] = "disk_find(): cannot find Disk by ID {}.".format(disk_id) + self.amodule.fail_json(**self.result) + if not check_state or ret_disk_facts['status'] not in DISK_INVALID_STATES: + return ret_disk_id, ret_disk_facts + else: + return 0, None + elif disk_name != "": + if account_id > 0: + # TODO: in the absense of disks/list or disks/search API call it is not possible to + # fully implement this method + # + self.result['failed'] = True + self.result['msg'] = "disk_find(): looking up disk by name and account ID not implemented." + self.amodule.fail_json(**self.result) + # + api_params = dict(accountId=account_id, + name=disk_name, + showAll=False) # we do not want to see disks in DESTROYED, PURGED or invalid states + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/search", api_params) + # the above call may return more than one matching disk + disks_list = json.loads(api_resp.content.decode('utf8')) + for runner in disks_list: + # return first disk on this list that fulfills status matching rule + if not check_state or ret_disk_facts['status'] not in DISK_INVALID_STATES: + return runner['id'], runner + else: + return 0, None + else: # we are missing meaningful account_id - fail the module + self.result['failed'] = True + self.result['msg'] = ("disk_find(): cannot find Disk by name '{}' " + "when no account ID specified.").format(disk_name) + self.amodule.fail_json(**self.result) + else: # Disk ID is 0 and Disk name is emtpy - fail the module + self.result['failed'] = True + self.result['msg'] = "disk_find(): cannot find Disk by zero ID and empty name." + self.amodule.fail_json(**self.result) + + return 0, None + + def disk_provision(self, disk_name, size, account_id, sep_id, pool="default", desc="", location=""): + """Provision Disk according to the specified arguments. + Note that disks created by this method will be of type 'D' (data disks). + If critical error occurs the embedded call to API function will abort further execution + of the script and relay error to Ansible. + + @param (string) disk_name: name to assign to the Disk. + @param (int) size: size of the disk in GB. + @param (int) account_id: ID of the account where disk will belong. + @param (int) sep_id: ID of the SEP (Storage Endpoint Provider), where disk will be created. + @param (string) pool: optional name of the pool, where this disk will be created. + @param (string) desc: optional text description of this disk. + @param (string) location: optional location, where disk resources will be provided. If empty + string is specified the disk will be created in the default location under DECORT controller. + + @return: ID of the newly created Disk (in Ansible check mode 0 is returned). + """ + + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "disk_provision") + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = "disk_provision() in check mode: create Disk name '{}' was requested.".format(disk_name) + return 0 + + target_gid = self.gid_get(location) + if not target_gid: + self.result['failed'] = True + self.result['msg'] = "disk_provision() failed to obtain Grid ID for default location." + self.amodule.fail_json(**self.result) + + ret_disk_id = 0 + api_params = dict(accountId=account_id, + gid=target_gid, + name=disk_name, + description=desc, + size=size, + type='D', + sep_id=sep_id, + pool=pool,) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/create", api_params) + if api_resp.status_code == 200: + ret_disk_id = json.loads(api_resp.content.decode('utf8')) + + return ret_disk_id + + def disk_resize(self, disk_facts, new_size): + """Resize Disk. Only increasing disk size is allowed. + + @param (dict) disk_dict: dictionary with target Disk details as returned by ../disks/get + API call or disk_find() method. + @param (int) new_size: new size of the disk in GB. It must be greater than current disk + size for the method to succeed. + """ + + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "disk_resize") + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = ("disk_resize() in check mode: resize Disk ID {} " + "to {} GB was requested.").format(disk_facts['id'], new_size) + return + + if not new_size: + self.result['failed'] = False + self.result['warning'] = "disk_resize(): zero size requested for Disk ID {} - ignoring.".format(disk_facts['id']) + return + + if new_size < disk_facts['sizeMax']: + self.result['failed'] = True + self.result['msg'] = ("disk_resize(): downsizing Disk ID {} is not allowed - current " + "size {}, requeste size {}.").format(disk_facts['id'], + disk_facts['sizeMax'], new_size) + return + + if new_size == disk_facts['sizeMax']: + self.result['failed'] = False + self.result['warning'] = ("disk_resize(): nothing to do for Disk ID {} - new size is the " + "same as current.").format(disk_facts['id']) + return + + api_params = dict(diskId=disk_facts['id'], size=new_size) + api_resp = self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/resize", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + self.result['failed'] = False + self.result['changed'] = True + disk_facts['sizeMax'] = new_size + + return + + def disk_restore(self, disk_id): + """Restores previously deleted Disk identified by its ID. For restore to succeed + the Disk must be in 'DELETED' state. + + @param disk_id: ID of the Disk to restore. + + @returns: nothing on success. On error this method will abort module execution. + """ + + self.result['waypoints'] = "{} -> {}".format(self.result['waypoints'], "disk_restore") + + if self.amodule.check_mode: + self.result['failed'] = False + self.result['msg'] = "disk_restore() in check mode: restore Disk ID {} was requested.".format(disk_id) + return + + api_params = dict(diskId=disk_id, + reason="Restored on user {} request by DECORT Ansible module.".format(self.decort_username),) + self.decort_api_call(requests.post, "/restmachine/cloudapi/disks/restore", api_params) + # On success the above call will return here. On error it will abort execution by calling fail_json. + self.result['failed'] = False + self.result['changed'] = True + return