1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00
community.general/plugins/modules/remote_management/lenovoxcc/xcc_redfish_command.py
panyy3 04f46f0435
add new module xcc_redfish_command to manage Lenovo servers using Redfish APIs ()
* add new module xcc_redfish_command to manage Lenovo servers using Redfish APIs

* Update plugins/modules/remote_management/lenovoxcc/xcc_redfish_command.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix some errors detected by ansible-test sanity

* end all descriptions (except short_description) with a period

* fix return definition problem and other errors detected by ansible-test sanity

* Always use true/false for booleans in YAML

* It is usually a good idea to leave away required: false

* fix errors detected by ansible-test sanity

* fix elements of command is not defined

* check whether resource_uri is specified for Raw commands

* if no Members property, return false; if empty array, return true

* get @odata.etag from patch body instead of getting again

* add request_body checking

* add unit test for the module

* fix errors detected by ansible-test sanity --test pep8

* update class name xcc_RedfishUtils to XCCRedfishUtils to follow convention; import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args, exit_json, fail_json from ansible_collections.community.general.tests.unit.plugins.modules.utils instead of inline them

* support using security token for auth

* fix line too long error

* As 2.3.0 got released yesterday, move to 2.4.0

* add maintainers for lenovoxcc

* update to make sure that it's sorted alphabetically

Co-authored-by: Felix Fontein <felix@fontein.de>
2021-03-24 15:07:17 +01:00

673 lines
25 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = '''
---
module: xcc_redfish_command
short_description: Manages Lenovo Out-Of-Band controllers using Redfish APIs
version_added: 2.4.0
description:
- Builds Redfish URIs locally and sends them to remote OOB controllers to
perform an action or get information back or update a configuration attribute.
- Manages virtual media.
- Supports getting information back via GET method.
- Supports updating a configuration attribute via PATCH method.
- Supports performing an action via POST method.
options:
category:
required: true
description:
- Category to execute on OOB controller.
type: str
command:
required: true
description:
- List of commands to execute on OOB controller.
type: list
elements: str
baseuri:
required: true
description:
- Base URI of OOB controller.
type: str
username:
description:
- Username for authentication with OOB controller.
type: str
password:
description:
- Password for authentication with OOB controller.
type: str
auth_token:
description:
- Security token for authentication with OOB controller
type: str
timeout:
description:
- Timeout in seconds for URL requests to OOB controller.
default: 10
type: int
resource_id:
required: false
description:
- The ID of the System, Manager or Chassis to modify.
type: str
virtual_media:
required: false
description:
- The options for VirtualMedia commands.
type: dict
suboptions:
media_types:
description:
- The list of media types appropriate for the image.
type: list
elements: str
image_url:
description:
- The URL of the image to insert or eject.
type: str
inserted:
description:
- Indicates if the image is treated as inserted on command completion.
type: bool
default: true
write_protected:
description:
- Indicates if the media is treated as write-protected.
type: bool
default: true
username:
description:
- The username for accessing the image URL.
type: str
password:
description:
- The password for accessing the image URL.
type: str
transfer_protocol_type:
description:
- The network protocol to use with the image.
type: str
transfer_method:
description:
- The transfer method to use with the image.
type: str
resource_uri:
required: false
description:
- The resource uri to get or patch or post.
type: str
request_body:
required: false
description:
- The request body to patch or post.
type: dict
author: "Yuyan Pan (@panyy3)"
'''
EXAMPLES = '''
- name: Insert Virtual Media
community.general.xcc_redfish_command:
category: Manager
command: VirtualMediaInsert
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
virtual_media:
image_url: "http://example.com/images/SomeLinux-current.iso"
media_types:
- CD
- DVD
resource_id: "1"
- name: Eject Virtual Media
community.general.xcc_redfish_command:
category: Manager
command: VirtualMediaEject
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
virtual_media:
image_url: "http://example.com/images/SomeLinux-current.iso"
resource_id: "1"
- name: Eject all Virtual Media
community.general.xcc_redfish_command:
category: Manager
command: VirtualMediaEject
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_id: "1"
- name: Get ComputeSystem Oem property SystemStatus via GetResource command
community.general.xcc_redfish_command:
category: Raw
command: GetResource
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_uri: "/redfish/v1/Systems/1"
register: result
- ansible.builtin.debug:
msg: "{{ result.redfish_facts.data.Oem.Lenovo.SystemStatus }}"
- name: Get Oem DNS setting via GetResource command
community.general.xcc_redfish_command:
category: Raw
command: GetResource
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_uri: "/redfish/v1/Managers/1/NetworkProtocol/Oem/Lenovo/DNS"
register: result
- ansible.builtin.debug:
msg: "{{ result.redfish_facts.data }}"
- name: Get Lenovo FoD key collection resource via GetCollectionResource command
community.general.xcc_redfish_command:
category: Raw
command: GetCollectionResource
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_uri: "/redfish/v1/Managers/1/Oem/Lenovo/FoD/Keys"
register: result
- ansible.builtin.debug:
msg: "{{ result.redfish_facts.data_list }}"
- name: Update ComputeSystem property AssetTag via PatchResource command
community.general.xcc_redfish_command:
category: Raw
command: PatchResource
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_uri: "/redfish/v1/Systems/1"
request_body:
AssetTag: "new_asset_tag"
- name: Perform BootToBIOSSetup action via PostResource command
community.general.xcc_redfish_command:
category: Raw
command: PostResource
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_uri: "/redfish/v1/Systems/1/Actions/Oem/LenovoComputerSystem.BootToBIOSSetup"
request_body: {}
- name: Perform SecureBoot.ResetKeys action via PostResource command
community.general.xcc_redfish_command:
category: Raw
command: PostResource
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
resource_uri: "/redfish/v1/Systems/1/SecureBoot/Actions/SecureBoot.ResetKeys"
request_body:
ResetKeysType: DeleteAllKeys
- name: Create session
community.general.redfish_command:
category: Sessions
command: CreateSession
baseuri: "{{ baseuri }}"
username: "{{ username }}"
password: "{{ password }}"
register: result
- name: Update Manager DateTimeLocalOffset property using security token for auth
community.general.xcc_redfish_command:
category: Raw
command: PatchResource
baseuri: "{{ baseuri }}"
auth_token: "{{ result.session.token }}"
resource_uri: "/redfish/v1/Managers/1"
request_body:
DateTimeLocalOffset: "+08:00"
- name: Delete session using security token created by CreateSesssion above
community.general.redfish_command:
category: Sessions
command: DeleteSession
baseuri: "{{ baseuri }}"
auth_token: "{{ result.session.token }}"
session_uri: "{{ result.session.uri }}"
'''
RETURN = '''
msg:
description: A message related to the performed action(s).
returned: when failure or action/update success
type: str
sample: "Action was successful"
redfish_facts:
description: Resource content.
returned: when command == GetResource or command == GetCollectionResource
type: dict
sample: '{
"redfish_facts": {
"data": {
"@odata.etag": "\"3179bf00d69f25a8b3c\"",
"@odata.id": "/redfish/v1/Managers/1/NetworkProtocol/Oem/Lenovo/DNS",
"@odata.type": "#LenovoDNS.v1_0_0.LenovoDNS",
"DDNS": [
{
"DDNSEnable": true,
"DomainName": "",
"DomainNameSource": "DHCP"
}
],
"DNSEnable": true,
"Description": "This resource is used to represent a DNS resource for a Redfish implementation.",
"IPv4Address1": "10.103.62.178",
"IPv4Address2": "0.0.0.0",
"IPv4Address3": "0.0.0.0",
"IPv6Address1": "::",
"IPv6Address2": "::",
"IPv6Address3": "::",
"Id": "LenovoDNS",
"PreferredAddresstype": "IPv4"
},
"ret": true
}
}'
'''
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native
from ansible_collections.community.general.plugins.module_utils.redfish_utils import RedfishUtils
class XCCRedfishUtils(RedfishUtils):
@staticmethod
def _find_empty_virt_media_slot(resources, media_types,
media_match_strict=True):
for uri, data in resources.items():
# check MediaTypes
if 'MediaTypes' in data and media_types:
if not set(media_types).intersection(set(data['MediaTypes'])):
continue
else:
if media_match_strict:
continue
if 'RDOC' in uri:
continue
# if ejected, 'Inserted' should be False and 'ImageName' cleared
if (not data.get('Inserted', False) and
not data.get('ImageName')):
return uri, data
return None, None
def virtual_media_eject_one(self, image_url):
# locate and read the VirtualMedia resources
response = self.get_request(self.root_uri + self.manager_uri)
if response['ret'] is False:
return response
data = response['data']
if 'VirtualMedia' not in data:
return {'ret': False, 'msg': "VirtualMedia resource not found"}
virt_media_uri = data["VirtualMedia"]["@odata.id"]
response = self.get_request(self.root_uri + virt_media_uri)
if response['ret'] is False:
return response
data = response['data']
virt_media_list = []
for member in data[u'Members']:
virt_media_list.append(member[u'@odata.id'])
resources, headers = self._read_virt_media_resources(virt_media_list)
# find the VirtualMedia resource to eject
uri, data, eject = self._find_virt_media_to_eject(resources, image_url)
if uri and eject:
if ('Actions' not in data or
'#VirtualMedia.EjectMedia' not in data['Actions']):
# try to eject via PATCH if no EjectMedia action found
h = headers[uri]
if 'allow' in h:
methods = [m.strip() for m in h.get('allow').split(',')]
if 'PATCH' not in methods:
# if Allow header present and PATCH missing, return error
return {'ret': False,
'msg': "%s action not found and PATCH not allowed"
% '#VirtualMedia.EjectMedia'}
return self.virtual_media_eject_via_patch(uri)
else:
# POST to the EjectMedia Action
action = data['Actions']['#VirtualMedia.EjectMedia']
if 'target' not in action:
return {'ret': False,
'msg': "target URI property missing from Action "
"#VirtualMedia.EjectMedia"}
action_uri = action['target']
# empty payload for Eject action
payload = {}
# POST to action
response = self.post_request(self.root_uri + action_uri,
payload)
if response['ret'] is False:
return response
return {'ret': True, 'changed': True,
'msg': "VirtualMedia ejected"}
elif uri and not eject:
# already ejected: return success but changed=False
return {'ret': True, 'changed': False,
'msg': "VirtualMedia image '%s' already ejected" %
image_url}
else:
# return failure (no resources matching image_url found)
return {'ret': False, 'changed': False,
'msg': "No VirtualMedia resource found with image '%s' "
"inserted" % image_url}
def virtual_media_eject(self, options):
if options:
image_url = options.get('image_url')
if image_url: # eject specified one media
return self.virtual_media_eject_one(image_url)
# eject all inserted media when no image_url specified
# read all the VirtualMedia resources
response = self.get_request(self.root_uri + self.manager_uri)
if response['ret'] is False:
return response
data = response['data']
if 'VirtualMedia' not in data:
return {'ret': False, 'msg': "VirtualMedia resource not found"}
virt_media_uri = data["VirtualMedia"]["@odata.id"]
response = self.get_request(self.root_uri + virt_media_uri)
if response['ret'] is False:
return response
data = response['data']
virt_media_list = []
for member in data[u'Members']:
virt_media_list.append(member[u'@odata.id'])
resources, headers = self._read_virt_media_resources(virt_media_list)
# eject all inserted media one by one
ejected_media_list = []
for uri, data in resources.items():
if data.get('Image') and data.get('Inserted', True):
returndict = self.virtual_media_eject_one(data.get('Image'))
if not returndict['ret']:
return returndict
ejected_media_list.append(data.get('Image'))
if len(ejected_media_list) == 0:
# no media inserted: return success but changed=False
return {'ret': True, 'changed': False,
'msg': "No VirtualMedia image inserted"}
else:
return {'ret': True, 'changed': True,
'msg': "VirtualMedia %s ejected" % str(ejected_media_list)}
def raw_get_resource(self, resource_uri):
if resource_uri is None:
return {'ret': False, 'msg': "resource_uri is missing"}
response = self.get_request(self.root_uri + resource_uri)
if response['ret'] is False:
return response
data = response['data']
return {'ret': True, 'data': data}
def raw_get_collection_resource(self, resource_uri):
if resource_uri is None:
return {'ret': False, 'msg': "resource_uri is missing"}
response = self.get_request(self.root_uri + resource_uri)
if response['ret'] is False:
return response
if 'Members' not in response['data']:
return {'ret': False, 'msg': "Specified resource_uri doesn't have Members property"}
member_list = [i['@odata.id'] for i in response['data'].get('Members', [])]
# get member resource one by one
data_list = []
for member_uri in member_list:
uri = self.root_uri + member_uri
response = self.get_request(uri)
if response['ret'] is False:
return response
data = response['data']
data_list.append(data)
return {'ret': True, 'data_list': data_list}
def raw_patch_resource(self, resource_uri, request_body):
if resource_uri is None:
return {'ret': False, 'msg': "resource_uri is missing"}
if request_body is None:
return {'ret': False, 'msg': "request_body is missing"}
# check whether resource_uri existing or not
response = self.get_request(self.root_uri + resource_uri)
if response['ret'] is False:
return response
original_etag = response['data']['@odata.etag']
# check validity of keys in request_body
data = response['data']
for key in request_body.keys():
if key not in data:
return {'ret': False, 'msg': "Key %s not found. Supported key list: %s" % (key, str(data.keys()))}
# perform patch
response = self.patch_request(self.root_uri + resource_uri, request_body)
if response['ret'] is False:
return response
# check whether changed or not
current_etag = ''
if 'data' in response and '@odata.etag' in response['data']:
current_etag = response['data']['@odata.etag']
if current_etag != original_etag:
return {'ret': True, 'changed': True}
else:
return {'ret': True, 'changed': False}
def raw_post_resource(self, resource_uri, request_body):
if resource_uri is None:
return {'ret': False, 'msg': "resource_uri is missing"}
if '/Actions/' not in resource_uri:
return {'ret': False, 'msg': "Bad uri %s. Keyword /Actions/ should be included in uri" % resource_uri}
if request_body is None:
return {'ret': False, 'msg': "request_body is missing"}
# get action base uri data for further checking
action_base_uri = resource_uri.split('/Actions/')[0]
response = self.get_request(self.root_uri + action_base_uri)
if response['ret'] is False:
return response
if 'Actions' not in response['data']:
return {'ret': False, 'msg': "Actions property not found in %s" % action_base_uri}
# check resouce_uri with target uri found in action base uri data
action_found = False
action_info_uri = None
action_target_uri_list = []
for key in response['data']['Actions'].keys():
if action_found:
break
if not key.startswith('#'):
continue
if 'target' in response['data']['Actions'][key]:
if resource_uri == response['data']['Actions'][key]['target']:
action_found = True
if '@Redfish.ActionInfo' in response['data']['Actions'][key]:
action_info_uri = response['data']['Actions'][key]['@Redfish.ActionInfo']
else:
action_target_uri_list.append(response['data']['Actions'][key]['target'])
if not action_found and 'Oem' in response['data']['Actions']:
for key in response['data']['Actions']['Oem'].keys():
if action_found:
break
if not key.startswith('#'):
continue
if 'target' in response['data']['Actions']['Oem'][key]:
if resource_uri == response['data']['Actions']['Oem'][key]['target']:
action_found = True
if '@Redfish.ActionInfo' in response['data']['Actions']['Oem'][key]:
action_info_uri = response['data']['Actions']['Oem'][key]['@Redfish.ActionInfo']
else:
action_target_uri_list.append(response['data']['Actions']['Oem'][key]['target'])
if not action_found:
return {'ret': False,
'msg': 'Specified resource_uri is not a supported action target uri, please specify a supported target uri instead. Supported uri: %s'
% (str(action_target_uri_list))}
# check request_body with parameter name defined by @Redfish.ActionInfo
if action_info_uri is not None:
response = self.get_request(self.root_uri + action_info_uri)
if response['ret'] is False:
return response
for key in request_body.keys():
key_found = False
for para in response['data']['Parameters']:
if key == para['Name']:
key_found = True
break
if not key_found:
return {'ret': False,
'msg': 'Invalid property %s found in request_body. Please refer to @Redfish.ActionInfo Parameters: %s'
% (key, str(response['data']['Parameters']))}
# perform post
response = self.post_request(self.root_uri + resource_uri, request_body)
if response['ret'] is False:
return response
return {'ret': True, 'changed': True}
# More will be added as module features are expanded
CATEGORY_COMMANDS_ALL = {
"Manager": ["VirtualMediaInsert",
"VirtualMediaEject"],
"Raw": ["GetResource",
"GetCollectionResource",
"PatchResource",
"PostResource"]
}
def main():
result = {}
module = AnsibleModule(
argument_spec=dict(
category=dict(required=True),
command=dict(required=True, type='list', elements='str'),
baseuri=dict(required=True),
username=dict(),
password=dict(no_log=True),
auth_token=dict(no_log=True),
timeout=dict(type='int', default=10),
resource_id=dict(),
virtual_media=dict(
type='dict',
options=dict(
media_types=dict(type='list', elements='str', default=[]),
image_url=dict(),
inserted=dict(type='bool', default=True),
write_protected=dict(type='bool', default=True),
username=dict(),
password=dict(no_log=True),
transfer_protocol_type=dict(),
transfer_method=dict(),
)
),
resource_uri=dict(),
request_body=dict(
type='dict',
),
),
required_together=[
('username', 'password'),
],
required_one_of=[
('username', 'auth_token'),
],
mutually_exclusive=[
('username', 'auth_token'),
],
supports_check_mode=False
)
category = module.params['category']
command_list = module.params['command']
# admin credentials used for authentication
creds = {'user': module.params['username'],
'pswd': module.params['password'],
'token': module.params['auth_token']}
# timeout
timeout = module.params['timeout']
# System, Manager or Chassis ID to modify
resource_id = module.params['resource_id']
# VirtualMedia options
virtual_media = module.params['virtual_media']
# resource_uri
resource_uri = module.params['resource_uri']
# request_body
request_body = module.params['request_body']
# Build root URI
root_uri = "https://" + module.params['baseuri']
rf_utils = XCCRedfishUtils(creds, root_uri, timeout, module, resource_id=resource_id, data_modification=True)
# Check that Category is valid
if category not in CATEGORY_COMMANDS_ALL:
module.fail_json(msg=to_native("Invalid Category '%s'. Valid Categories = %s" % (category, CATEGORY_COMMANDS_ALL.keys())))
# Check that all commands are valid
for cmd in command_list:
# Fail if even one command given is invalid
if cmd not in CATEGORY_COMMANDS_ALL[category]:
module.fail_json(msg=to_native("Invalid Command '%s'. Valid Commands = %s" % (cmd, CATEGORY_COMMANDS_ALL[category])))
# Organize by Categories / Commands
if category == "Manager":
# execute only if we find a Manager service resource
result = rf_utils._find_managers_resource()
if result['ret'] is False:
module.fail_json(msg=to_native(result['msg']))
for command in command_list:
if command == 'VirtualMediaInsert':
result = rf_utils.virtual_media_insert(virtual_media)
elif command == 'VirtualMediaEject':
result = rf_utils.virtual_media_eject(virtual_media)
elif category == "Raw":
for command in command_list:
if command == 'GetResource':
result = rf_utils.raw_get_resource(resource_uri)
elif command == 'GetCollectionResource':
result = rf_utils.raw_get_collection_resource(resource_uri)
elif command == 'PatchResource':
result = rf_utils.raw_patch_resource(resource_uri, request_body)
elif command == 'PostResource':
result = rf_utils.raw_post_resource(resource_uri, request_body)
# Return data back or fail with proper message
if result['ret'] is True:
if command == 'GetResource' or command == 'GetCollectionResource':
module.exit_json(redfish_facts=result)
else:
changed = result.get('changed', True)
msg = result.get('msg', 'Action was successful')
module.exit_json(changed=changed, msg=msg)
else:
module.fail_json(msg=to_native(result['msg']))
if __name__ == '__main__':
main()