From 63ea76d174ab80a3f32492c0da5ae6cafccea32e Mon Sep 17 00:00:00 2001 From: David Soper Date: Tue, 19 Feb 2019 09:40:40 -0600 Subject: [PATCH] intersight_rest_api module and integration tests. (#52430) * intersight_rest_api module and integration tests. Fix intersight module_utils issues when using POST/PATCH/DELETE. * Update json returns based on code review --- .../remote_management/intersight.py | 7 +- .../intersight/intersight_rest_api.py | 252 ++++++++++++++++++ .../targets/intersight_rest_api/aliases | 3 + .../intersight_rest_api/tasks/main.yml | 152 +++++++++++ 4 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 lib/ansible/modules/remote_management/intersight/intersight_rest_api.py create mode 100644 test/integration/targets/intersight_rest_api/aliases create mode 100644 test/integration/targets/intersight_rest_api/tasks/main.yml diff --git a/lib/ansible/module_utils/remote_management/intersight.py b/lib/ansible/module_utils/remote_management/intersight.py index 520344f205e..25329726d22 100644 --- a/lib/ansible/module_utils/remote_management/intersight.py +++ b/lib/ansible/module_utils/remote_management/intersight.py @@ -186,11 +186,13 @@ class IntersightModule(): try: response, info = self.intersight_call(**options) if not re.match(r'2..', str(info['status'])): - raise RuntimeError(info['status'], info['msg']) + raise RuntimeError(info['status'], info['msg'], info['body']) except Exception as e: self.module.fail_json(msg="API error: %s " % str(e)) - return json.loads(response.read()) + if response.length > 0: + return json.loads(response.read()) + return {} def intersight_call(self, http_method="", resource_path="", query_params=None, body=None, moid=None, name=None): """ @@ -278,6 +280,7 @@ class IntersightModule(): # Generate the HTTP requests header request_header = { 'Accept': 'application/json', + 'Content-Type': 'application/json', 'Host': '{0}'.format(target_host), 'Date': '{0}'.format(cdate), 'Digest': 'SHA-256={0}'.format(b64_body_digest.decode('ascii')), diff --git a/lib/ansible/modules/remote_management/intersight/intersight_rest_api.py b/lib/ansible/modules/remote_management/intersight/intersight_rest_api.py new file mode 100644 index 00000000000..eb4e211d5ed --- /dev/null +++ b/lib/ansible/modules/remote_management/intersight/intersight_rest_api.py @@ -0,0 +1,252 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: intersight_rest_api +short_description: REST API configuration for Cisco Intersight +description: +- Direct REST API configuration for Cisco Intersight. +- All REST API resources and properties must be specified. +- For more information see L(Cisco Intersight,https://intersight.com/apidocs). +extends_documentation_fragment: intersight +options: + resource_path: + description: + - Resource URI being configured related to api_uri. + type: str + required: yes + query_params: + description: + - Query parameters for the Intersight API query languange. + type: dict + update_method: + description: + - The HTTP method used for update operations. + - Some Intersight resources require POST operations for modifications. + type: str + choices: [ patch, post ] + default: patch + api_body: + description: + - The paylod for API requests used to modify resources. + type: dict + state: + description: + - If C(present), will verify the resource is present and will create if needed. + - If C(absent), will verify the resource is absent and will delete if needed. + choices: [present, absent] + default: present +author: +- David Soper (@dsoper2) +- CiscoUcs (@CiscoUcs) +version_added: '2.8' +''' + +EXAMPLES = r''' +- name: Configure Boot Policy + intersight_rest_api: + api_private_key: "{{ api_private_key }}" + api_key_id: "{{ api_key_id }}" + api_key_uri: "{{ api_key_uri }}" + validate_certs: "{{ validate_certs }}" + resource_path: /boot/PrecisionPolicies + query_params: + $filter: "Name eq 'vmedia-localdisk'" + api_body: { + "Name": "vmedia-hdd", + "ConfiguredBootMode": "Legacy", + "BootDevices": [ + { + "ObjectType": "boot.VirtualMedia", + "Enabled": true, + "Name": "remote-vmedia", + "Subtype": "cimc-mapped-dvd" + }, + { + "ObjectType": "boot.LocalDisk", + "Enabled": true, + "Name": "localdisk", + "Slot": "MRAID", + "Bootloader": null + } + ], + } + state: present + +- name: Delete Boot Policy + intersight_rest_api: + api_private_key: "{{ api_private_key }}" + api_key_id: "{{ api_key_id }}" + api_key_uri: "{{ api_key_uri }}" + validate_certs: "{{ validate_certs }}" + resource_path: /boot/PrecisionPolicies + query_params: + $filter: "Name eq 'vmedia-localdisk'" + state: absent +''' + +RETURN = r''' +api_repsonse: + description: The API response output returned by the specified resource. + returned: always + type: dict + sample: + "api_response": { + "BootDevices": [ + { + "Enabled": true, + "Name": "remote-vmedia", + "ObjectType": "boot.VirtualMedia", + "Subtype": "cimc-mapped-dvd" + }, + { + "Bootloader": null, + "Enabled": true, + "Name": "boot-lun", + "ObjectType": "boot.LocalDisk", + "Slot": "MRAID" + } + ], + "ConfiguredBootMode": "Legacy", + "Name": "vmedia-localdisk", + "ObjectType": "boot.PrecisionPolicy", + } +''' + + +import re +from ansible.module_utils.remote_management.intersight import IntersightModule, intersight_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import iteritems + + +def get_resource(intersight): + ''' + GET a resource and return the 1st element found + ''' + options = { + 'http_method': 'get', + 'resource_path': intersight.module.params['resource_path'], + 'query_params': intersight.module.params['query_params'], + } + response_dict = intersight.call_api(**options) + if response_dict.get('Results'): + # return the 1st list element + response_dict = response_dict['Results'][0] + + return response_dict + + +def compare_values(expected, actual): + try: + for (key, value) in iteritems(expected): + if re.search(r'P(ass)?w(or)?d', key) or not actual.get(key): + # do not compare any password related attributes or attributes that are not in the actual resource + continue + if not compare_values(value, actual[key]): + return False + # loop complete with all items matching + return True + except (AttributeError, TypeError): + if expected and actual != expected: + return False + return True + + +def configure_resource(intersight, moid): + if not intersight.module.check_mode: + if moid: + # update the resource - user has to specify all the props they want updated + options = { + 'http_method': intersight.module.params['update_method'], + 'resource_path': intersight.module.params['resource_path'], + 'body': intersight.module.params['api_body'], + 'moid': moid, + } + response_dict = intersight.call_api(**options) + if response_dict.get('Results'): + # return the 1st element in the results list + intersight.result['api_response'] = response_dict['Results'][0] + else: + # create the resource + options = { + 'http_method': 'post', + 'resource_path': intersight.module.params['resource_path'], + 'body': intersight.module.params['api_body'], + } + intersight.call_api(**options) + intersight.result['api_response'] = get_resource(intersight) + intersight.result['changed'] = True + + +def delete_resource(intersight, moid): + # delete resource and create empty api_response + if not intersight.module.check_mode: + options = { + 'http_method': 'delete', + 'resource_path': intersight.module.params['resource_path'], + 'moid': moid, + } + intersight.call_api(**options) + intersight.result['api_response'] = {} + intersight.result['changed'] = True + + +def main(): + argument_spec = intersight_argument_spec + argument_spec.update( + resource_path=dict(type='str', required=True), + query_params=dict(type='dict', default={}), + update_method=dict(type='str', choices=['patch', 'post'], default='patch'), + api_body=dict(type='dict', default={}), + state=dict(type='str', choices=['absent', 'present'], default='present'), + ) + + module = AnsibleModule( + argument_spec, + supports_check_mode=True, + ) + + intersight = IntersightModule(module) + intersight.result['api_response'] = {} + + # get the current state of the resource + intersight.result['api_response'] = get_resource(intersight) + + # determine requested operation (config, delete, or neither (get resource only)) + if module.params['state'] == 'present': + request_delete = False + # api_body implies resource configuration through post/patch + request_config = bool(module.params['api_body']) + else: # state == 'absent' + request_delete = True + request_config = False + + moid = None + resource_values_match = False + if (request_config or request_delete) and intersight.result['api_response'].get('Moid'): + # resource exists and moid was returned + moid = intersight.result['api_response']['Moid'] + if request_config: + resource_values_match = compare_values(module.params['api_body'], intersight.result['api_response']) + else: # request_delete + delete_resource(intersight, moid) + + if request_config and not resource_values_match: + configure_resource(intersight, moid) + + module.exit_json(**intersight.result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/intersight_rest_api/aliases b/test/integration/targets/intersight_rest_api/aliases new file mode 100644 index 00000000000..f1ada40c2c6 --- /dev/null +++ b/test/integration/targets/intersight_rest_api/aliases @@ -0,0 +1,3 @@ +# Not enabled, but can be used with Intersight by specifying API keys. +# See tasks/main.yml for examples. +unsupported diff --git a/test/integration/targets/intersight_rest_api/tasks/main.yml b/test/integration/targets/intersight_rest_api/tasks/main.yml new file mode 100644 index 00000000000..51f010d57dc --- /dev/null +++ b/test/integration/targets/intersight_rest_api/tasks/main.yml @@ -0,0 +1,152 @@ +--- +# Test code for the Cisco Intersight modules +# Copyright 2019, David Soper (@dsoper2) + +- name: Setup API access variables + debug: msg="Setup API keys" + vars: + api_info: &api_info + api_private_key: "{{ api_private_key | default('~/Downloads/SSOSecretKey.txt') }}" + api_key_id: "{{ api_key_id | default('596cc79e5d91b400010d15ad/596cc7945d91b400010d154e/5b6275df3437357030a7795f') }}" + +# Setup (clean environment) +- name: Boot policy Absent + intersight_rest_api: &boot_policy_absent + <<: *api_info + resource_path: /boot/PrecisionPolicies + query_params: + $filter: "Name eq 'vmedia-localdisk'" + state: absent + +# Test present (check_mode) +- name: Boot policy present (check_mode) + intersight_rest_api: &boot_policy_present + <<: *api_info + resource_path: /boot/PrecisionPolicies + query_params: + $filter: "Name eq 'vmedia-localdisk'" + api_body: { + "Name": "vmedia-localdisk", + "ConfiguredBootMode": "Legacy", + "BootDevices": [ + { + "ObjectType": "boot.VirtualMedia", + "Enabled": true, + "Name": "remote-vmedia", + "Subtype": "cimc-mapped-dvd" + }, + { + "ObjectType": "boot.LocalDisk", + "Enabled": true, + "Name": "localdisk", + "Slot": "MRAID", + "Bootloader": null + } + ], + } + check_mode: true + register: cm_boot_policy_present + +# Present (normal mode) +- name: Boot policy present (normal mode) + intersight_rest_api: *boot_policy_present + register: nm_boot_policy_present + +# Test present again (idempotent) +- name: Boot policy present again (check_mode) + intersight_rest_api: *boot_policy_present + check_mode: true + register: cm_boot_policy_present_again + +# Present again (normal mode) +- name: Boot policy present again (normal mode) + intersight_rest_api: *boot_policy_present + register: nm_boot_policy_present_again + +# Verfiy present +- name: Verify Boot policy present results + assert: + that: + - cm_boot_policy_present.changed == nm_boot_policy_present.changed == true + - cm_boot_policy_present_again.changed == nm_boot_policy_present_again.changed == false + +# Test change (check_mode) +- name: Boot policy change (check_mode) + intersight_rest_api: &boot_policy_change + <<: *api_info + resource_path: /boot/PrecisionPolicies + query_params: + $filter: "Name eq 'vmedia-localdisk'" + api_body: { + "Name": "vmedia-localdisk", + "ConfiguredBootMode": "Legacy", + "BootDevices": [ + { + "ObjectType": "boot.VirtualMedia", + "Enabled": true, + "Name": "remote-vmedia", + "Subtype": "cimc-mapped-dvd" + }, + { + "ObjectType": "boot.LocalDisk", + "Enabled": true, + "Name": "localdisk", + "Slot": "HBA", + "Bootloader": null + } + ], + } + check_mode: true + register: cm_boot_policy_change + +# Change (normal mode) +- name: Boot policy change (normal mode) + intersight_rest_api: *boot_policy_change + register: nm_boot_policy_change + +# Test change again (idempotent) +- name: Boot policy again (check_mode) + intersight_rest_api: *boot_policy_change + check_mode: true + register: cm_boot_policy_change_again + +# Change again (normal mode) +- name: Boot policy change again (normal mode) + intersight_rest_api: *boot_policy_change + register: nm_boot_policy_change_again + +# Verfiy change +- name: Verify Boot policy change results + assert: + that: + - cm_boot_policy_change.changed == nm_boot_policy_change.changed == true + - cm_boot_policy_change_again.changed == nm_boot_policy_change_again.changed == false + +# Teardown (clean environment) +- name: Boot policy absent (check_mode) + intersight_rest_api: *boot_policy_absent + check_mode: true + register: cm_boot_policy_absent + +# Absent (normal mode) +- name: Boot policy absent (normal mode) + intersight_rest_api: *boot_policy_absent + register: nm_boot_policy_absent + +# Test absent again (idempotent) +- name: Boot policy absent again (check_mode) + intersight_rest_api: *boot_policy_absent + check_mode: true + register: cm_boot_policy_absent_again + +# Absent again (normal mode) +- name: Boot policy absent again (normal mode) + intersight_rest_api: *boot_policy_absent + register: nm_boot_policy_absent_again + +# Verfiy absent +- name: Verify Boot policy absent results + assert: + that: + - cm_boot_policy_absent.changed == nm_boot_policy_absent.changed == true + - cm_boot_policy_absent_again.changed == nm_boot_policy_absent_again.changed == false