From 958653e282a109fa31656a5d53a4fa1e508d91ac Mon Sep 17 00:00:00 2001 From: David Soper Date: Fri, 15 Feb 2019 09:32:29 -0600 Subject: [PATCH] Cisco Intersight module_utils and intersight_facts module (#51309) * Cisco Intersight module_utils and intersight_facts module * Add RETURN information and fix pylint, import, and pep8 issues. * Review updates for specifying type of params/returns and not polluting ansible_facts. * BSD one line license, validate_certs used, urls.fetch_urls replaces requests --- .../remote_management/intersight.py | 289 ++++++++++++++++++ .../remote_management/intersight/__init__.py | 0 .../intersight/intersight_facts.py | 113 +++++++ .../plugins/doc_fragments/intersight.py | 45 +++ .../targets/intersight_facts/aliases | 3 + .../targets/intersight_facts/tasks/main.yml | 37 +++ 6 files changed, 487 insertions(+) create mode 100644 lib/ansible/module_utils/remote_management/intersight.py create mode 100644 lib/ansible/modules/remote_management/intersight/__init__.py create mode 100644 lib/ansible/modules/remote_management/intersight/intersight_facts.py create mode 100644 lib/ansible/plugins/doc_fragments/intersight.py create mode 100644 test/integration/targets/intersight_facts/aliases create mode 100644 test/integration/targets/intersight_facts/tasks/main.yml diff --git a/lib/ansible/module_utils/remote_management/intersight.py b/lib/ansible/module_utils/remote_management/intersight.py new file mode 100644 index 00000000000..520344f205e --- /dev/null +++ b/lib/ansible/module_utils/remote_management/intersight.py @@ -0,0 +1,289 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# (c) 2016 Red Hat Inc. +# (c) 2018 Cisco Systems Inc. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Intersight REST API Module +# Author: Matthew Garrett +# Contributors: David Soper, Chris Gascoigne, John McDonough + +from base64 import b64encode +from email.utils import formatdate +import re +import json +import hashlib +from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode, quote +from ansible.module_utils.urls import fetch_url + +try: + from cryptography.hazmat.primitives import serialization, hashes + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.backends import default_backend + HAS_CRYPTOGRAPHY = True +except ImportError: + HAS_CRYPTOGRAPHY = False + +intersight_argument_spec = dict( + api_private_key=dict(type='path', required=True), + api_uri=dict(type='str', default='https://intersight.com/api/v1'), + api_key_id=dict(type='str', required=True), + validate_certs=dict(type='bool', default=True), + use_proxy=dict(type='bool', default=True), +) + + +def get_sha256_digest(data): + """ + Generates a SHA256 digest from a String. + + :param data: data string set by user + :return: instance of digest object + """ + + digest = hashlib.sha256() + digest.update(data.encode()) + + return digest + + +def prepare_str_to_sign(req_tgt, hdrs): + """ + Concatenates Intersight headers in preparation to be RSA signed + + :param req_tgt : http method plus endpoint + :param hdrs: dict with header keys + :return: concatenated header authorization string + """ + ss = "" + ss = ss + "(request-target): " + req_tgt.lower() + "\n" + + length = len(hdrs.items()) + + i = 0 + for key, value in hdrs.items(): + ss = ss + key.lower() + ": " + value + if i < length - 1: + ss = ss + "\n" + i += 1 + + return ss + + +def get_gmt_date(): + """ + Generated a GMT formatted Date + + :return: current date + """ + + return formatdate(timeval=None, localtime=False, usegmt=True) + + +class IntersightModule(): + + def __init__(self, module): + self.module = module + self.result = dict(changed=False) + if not HAS_CRYPTOGRAPHY: + self.module.fail_json(msg='cryptography is required for this module') + self.host = self.module.params['api_uri'] + self.public_key = self.module.params['api_key_id'] + self.private_key = open(self.module.params['api_private_key'], 'r').read() + self.digest_algorithm = 'rsa-sha256' + self.response_list = [] + + def get_rsasig_b64encode(self, data): + """ + Generates an RSA Signed SHA256 digest from a String + + :param digest: string to be signed & hashed + :return: instance of digest object + """ + + rsakey = serialization.load_pem_private_key(self.private_key.encode(), None, default_backend()) + sign = rsakey.sign(data.encode(), padding.PKCS1v15(), hashes.SHA256()) + + return b64encode(sign) + + def get_auth_header(self, hdrs, signed_msg): + """ + Assmebled an Intersight formatted authorization header + + :param hdrs : object with header keys + :param signed_msg: base64 encoded sha256 hashed body + :return: concatenated authorization header + """ + + auth_str = "Signature" + + auth_str = auth_str + " " + "keyId=\"" + self.public_key + "\"," + "algorithm=\"" + self.digest_algorithm + "\"," + "headers=\"(request-target)" + + for key, dummy in hdrs.items(): + auth_str = auth_str + " " + key.lower() + auth_str = auth_str + "\"" + + auth_str = auth_str + "," + "signature=\"" + signed_msg.decode('ascii') + "\"" + + return auth_str + + def get_moid_by_name(self, resource_path, target_name): + """ + Retrieve an Intersight object moid by name + + :param resource_path: intersight resource path e.g. '/ntp/Policies' + :param target_name: intersight object name + :return: json http response object + """ + query_params = { + "$filter": "Name eq '{0}'".format(target_name) + } + + options = { + "http_method": "GET", + "resource_path": resource_path, + "query_params": query_params + } + + get_moid = self.intersight_call(**options) + + if get_moid.json()['Results'] is not None: + located_moid = get_moid.json()['Results'][0]['Moid'] + else: + raise KeyError('Intersight object with name "{0}" not found!'.format(target_name)) + + return located_moid + + def call_api(self, **options): + """ + Call the Intersight API and check for success status + :param options: options dict with method and other params for API call + :return: json http response object + """ + + try: + response, info = self.intersight_call(**options) + if not re.match(r'2..', str(info['status'])): + raise RuntimeError(info['status'], info['msg']) + except Exception as e: + self.module.fail_json(msg="API error: %s " % str(e)) + + return json.loads(response.read()) + + def intersight_call(self, http_method="", resource_path="", query_params=None, body=None, moid=None, name=None): + """ + Invoke the Intersight API + + :param resource_path: intersight resource path e.g. '/ntp/Policies' + :param query_params: dictionary object with query string parameters as key/value pairs + :param body: dictionary object with intersight data + :param moid: intersight object moid + :param name: intersight object name + :return: json http response object + """ + + target_host = urlparse(self.host).netloc + target_path = urlparse(self.host).path + query_path = "" + method = http_method.upper() + bodyString = "" + + # Verify an accepted HTTP verb was chosen + if(method not in ['GET', 'POST', 'PATCH', 'DELETE']): + raise ValueError('Please select a valid HTTP verb (GET/POST/PATCH/DELETE)') + + # Verify the resource path isn't empy & is a valid object + if(resource_path != "" and not (resource_path, str)): + raise TypeError('The *resource_path* value is required and must be of type ""') + + # Verify the query parameters isn't empy & is a valid object + if(query_params is not None and not isinstance(query_params, dict)): + raise TypeError('The *query_params* value must be of type ""') + + # Verify the body isn't empy & is a valid object + if(body is not None and not isinstance(body, dict)): + raise TypeError('The *body* value must be of type ""') + + # Verify the MOID is not null & of proper length + if(moid is not None and len(moid.encode('utf-8')) != 24): + raise ValueError('Invalid *moid* value!') + + # Check for query_params, encode, and concatenate onto URL + if query_params is not None: + query_path = "?" + urlencode(query_params).replace('+', '%20') + + # Handle PATCH/DELETE by Object "name" instead of "moid" + if(method == "PATCH" or method == "DELETE"): + if moid is None: + if name is not None: + if isinstance(name, str): + moid = self.get_moid_by_name(resource_path, name) + else: + raise TypeError('The *name* value must be of type ""') + else: + raise ValueError('Must set either *moid* or *name* with "PATCH/DELETE!"') + + # Check for moid and concatenate onto URL + if moid is not None: + resource_path += "/" + moid + + # Check for GET request to properly form body + if method != "GET": + bodyString = json.dumps(body) + + # Concatenate URLs for headers + target_url = self.host + resource_path + query_path + request_target = method + " " + target_path + resource_path + query_path + + # Get the current GMT Date/Time + cdate = get_gmt_date() + + # Generate the body digest + body_digest = get_sha256_digest(bodyString) + b64_body_digest = b64encode(body_digest.digest()) + + # Generate the authorization header + auth_header = { + 'Date': cdate, + 'Host': target_host, + 'Digest': "SHA-256=" + b64_body_digest.decode('ascii') + } + + string_to_sign = prepare_str_to_sign(request_target, auth_header) + b64_signed_msg = self.get_rsasig_b64encode(string_to_sign) + auth_header = self.get_auth_header(auth_header, b64_signed_msg) + + # Generate the HTTP requests header + request_header = { + 'Accept': 'application/json', + 'Host': '{0}'.format(target_host), + 'Date': '{0}'.format(cdate), + 'Digest': 'SHA-256={0}'.format(b64_body_digest.decode('ascii')), + 'Authorization': '{0}'.format(auth_header), + } + + response, info = fetch_url(self.module, target_url, data=bodyString, headers=request_header, method=method, use_proxy=self.module.params['use_proxy']) + + return response, info diff --git a/lib/ansible/modules/remote_management/intersight/__init__.py b/lib/ansible/modules/remote_management/intersight/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/modules/remote_management/intersight/intersight_facts.py b/lib/ansible/modules/remote_management/intersight/intersight_facts.py new file mode 100644 index 00000000000..442008308d5 --- /dev/null +++ b/lib/ansible/modules/remote_management/intersight/intersight_facts.py @@ -0,0 +1,113 @@ +#!/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_facts +short_description: Gather facts about Intersight +description: +- Gathers facts about servers in L(Cisco Intersight,https://intersight.com). +extends_documentation_fragment: intersight +options: + server_names: + description: + - Server names to retrieve facts from. + - An empty list will return all servers. + type: list + required: yes +author: +- David Soper (@dsoper2) +- CiscoUcs (@CiscoUcs) +version_added: '2.8' +''' + +EXAMPLES = r''' +- name: Get facts for all servers + intersight_facts: + api_private_key: ~/Downloads/SecretKey.txt + api_key_id: 64612d300d0982/64612d300d0b00/64612d300d3650 + server_names: +- debug: + msg: "server name {{ item.Name }}, moid {{ item.Moid }}" + loop: "{{ intersight_servers }}" + when: intersight_servers is defined + +- name: Get facts for servers by name + intersight_facts: + api_private_key: ~/Downloads/SecretKey.txt + api_key_id: 64612d300d0982/64612d300d0b00/64612d300d3650 + server_names: + - SJC18-L14-UCS1-1 +- debug: + msg: "server moid {{ intersight_servers[0].Moid }}" + when: intersight_servers[0] is defined +''' + +RETURN = r''' +intersight_servers: + description: A list of Intersight Servers. See L(Cisco Intersight,https://intersight.com/apidocs) for details. + returned: always + type: complex + contains: + Name: + description: The name of the server. + returned: always + type: str + sample: SJC18-L14-UCS1-1 + Moid: + description: The unique identifier of this Managed Object instance. + returned: always + type: str + sample: 5978bea36ad4b000018d63dc +''' + +from ansible.module_utils.remote_management.intersight import IntersightModule, intersight_argument_spec +from ansible.module_utils.basic import AnsibleModule + + +def get_servers(module, intersight): + query_list = [] + if module.params['server_names']: + for server in module.params['server_names']: + query_list.append("Name eq '%s'" % server) + query_str = ' or '.join(query_list) + options = { + 'http_method': 'get', + 'resource_path': '/compute/PhysicalSummaries', + 'query_params': { + '$filter': query_str, + } + } + response_dict = intersight.call_api(**options) + + return response_dict.get('Results') + + +def main(): + argument_spec = intersight_argument_spec + argument_spec.update( + server_names=dict(type='list', required=True), + ) + + module = AnsibleModule( + argument_spec, + supports_check_mode=True, + ) + + intersight = IntersightModule(module) + + # one API call returning all requested servers + module.exit_json(intersight_servers=get_servers(module, intersight)) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/doc_fragments/intersight.py b/lib/ansible/plugins/doc_fragments/intersight.py new file mode 100644 index 00000000000..b64d4c88362 --- /dev/null +++ b/lib/ansible/plugins/doc_fragments/intersight.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# (c) 2016 Red Hat Inc. +# (c) 2017 Cisco Systems Inc. +# +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) +# + + +class ModuleDocFragment(object): + # Cisco Intersight doc fragment + DOCUMENTATION = ''' +options: + api_private_key: + description: + - 'Filename (absolute path) of a PEM formatted file that contains your private key to be used for Intersight API authentication.' + type: path + required: yes + api_uri: + description: + - URI used to access the Intersight API. + type: str + default: https://intersight.com/api/v1 + api_key_id: + description: + - Public API Key ID associated with the private key. + type: str + required: yes + validate_certs: + description: + - Boolean control for verifying the api_uri TLS certificate + type: bool + default: yes + use_proxy: + description: + - If C(no), it will not use a proxy, even if one is defined in an environment variable on the target hosts. + type: bool + default: yes +''' diff --git a/test/integration/targets/intersight_facts/aliases b/test/integration/targets/intersight_facts/aliases new file mode 100644 index 00000000000..f1ada40c2c6 --- /dev/null +++ b/test/integration/targets/intersight_facts/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_facts/tasks/main.yml b/test/integration/targets/intersight_facts/tasks/main.yml new file mode 100644 index 00000000000..d12d3aa12c7 --- /dev/null +++ b/test/integration/targets/intersight_facts/tasks/main.yml @@ -0,0 +1,37 @@ +--- +# 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') }}" + +- name: Get facts for all servers + intersight_facts: + <<: *api_info + server_names: + register: result + +- name: Verify facts does not report a change + assert: + that: + - result.changed == false + - "'Name' in result.intersight_servers[0]" + - "'Moid' in result.intersight_servers[0]" + +- name: Get facts for servers by name + intersight_facts: + <<: *api_info + server_names: + - CC7UCS-13-1-1 + register: result2 + +- name: Verify facts does not report a change + assert: + that: + - result2.changed == false + - "'Name' in result2.intersight_servers[0]" + - "'Moid' in result2.intersight_servers[0]"