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
This commit is contained in:
David Soper 2019-02-15 09:32:29 -06:00 committed by Dag Wieers
parent c2fb581414
commit 958653e282
6 changed files with 487 additions and 0 deletions

View file

@ -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 <str> object
if(resource_path != "" and not (resource_path, str)):
raise TypeError('The *resource_path* value is required and must be of type "<str>"')
# Verify the query parameters isn't empy & is a valid <dict> object
if(query_params is not None and not isinstance(query_params, dict)):
raise TypeError('The *query_params* value must be of type "<dict>"')
# Verify the body isn't empy & is a valid <dict> object
if(body is not None and not isinstance(body, dict)):
raise TypeError('The *body* value must be of type "<dict>"')
# 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 "<str>"')
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

View file

@ -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()

View file

@ -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
'''

View file

@ -0,0 +1,3 @@
# Not enabled, but can be used with Intersight by specifying API keys.
# See tasks/main.yml for examples.
unsupported

View file

@ -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]"