From 89bf99c4d010d9e9f99f0c73ccc3b682874e77a4 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Fri, 27 Oct 2023 13:07:37 +0200 Subject: [PATCH 01/12] license key automation: first working version incl. error handling - docs missing --- plugins/module_utils/constants.py | 1 + plugins/module_utils/sap_api_common.py | 2 +- .../sap_launchpad_systems_runner.py | 292 ++++++++++++++++++ plugins/modules/license_keys.py | 143 +++++++++ plugins/modules/systems_info.py | 54 ++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 plugins/module_utils/sap_launchpad_systems_runner.py create mode 100644 plugins/modules/license_keys.py create mode 100644 plugins/modules/systems_info.py diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 59a1216..5d25e07 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -6,6 +6,7 @@ URL_SERVICE_INCIDENT = 'https://launchpad.support.sap.com/services/odata/incidentws' URL_SERVICE_USER_ADMIN = 'https://launchpad.support.sap.com/services/odata/useradminsrv' URL_SOFTWARE_DOWNLOAD = 'https://softwaredownloads.sap.com' +URL_SYSTEMS_PROVISIONING = 'https://launchpad.support.sap.com/services/odata/i7p/odata/bkey' # Maintainance Planner URL_MAINTAINANCE_PLANNER = 'https://maintenanceplanner.cfapps.eu10.hana.ondemand.com' URL_USERAPPS = 'https://userapps.support.sap.com/sap/support/mp/index.html' diff --git a/plugins/module_utils/sap_api_common.py b/plugins/module_utils/sap_api_common.py index bc428dd..4507416 100644 --- a/plugins/module_utils/sap_api_common.py +++ b/plugins/module_utils/sap_api_common.py @@ -37,7 +37,7 @@ def _request(url, **kwargs): if 'allow_redirects' not in kwargs: kwargs['allow_redirects'] = True - method = 'POST' if kwargs.get('data') else 'GET' + method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET' res = https_session.request(method, url, **kwargs) res.raise_for_status() diff --git a/plugins/module_utils/sap_launchpad_systems_runner.py b/plugins/module_utils/sap_launchpad_systems_runner.py new file mode 100644 index 0000000..5b1478d --- /dev/null +++ b/plugins/module_utils/sap_launchpad_systems_runner.py @@ -0,0 +1,292 @@ +from . import constants as C +from .sap_api_common import _request +import json + +from requests.exceptions import HTTPError + + +class InstallationNotFoundError(Exception): + def __init__(self, installation_nr, available_installations): + self.installation_nr = installation_nr + self.available_installations = available_installations + + +def validate_installation(installation_nr, username): + query_path = f"Installations?$filter=Ubname eq '{username}' and ValidateOnly eq ''" + installations = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + if not any(installation['Insnr'] == installation_nr for installation in installations): + raise InstallationNotFoundError(installation_nr, installations) + + +def get_systems(filter): + query_path = f"Systems?$filter={filter}" + return _request(_url(query_path), headers=_headers({})).json()['d']['results'] + + +class SystemNrInvalidError(Exception): + def __init__(self, system_nr, details): + self.system_nr = system_nr + self.details = details + + +def get_system(system_nr, installation_nr, username): + query_path = f"Systems?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '{system_nr}'" + + try: + systems = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + except HTTPError as err: + # in case the system is not found, the backend doesn't return an empty result set or a 404, but a 400. + # to make the error checking here as resilient as possible, + # just consider an error 400 as an invalid user error and return it to the user. + if err.response.status_code == 400: + raise SystemNrInvalidError(system_nr, err.response.content) + else: + raise err + + # not sure this case ever happens; catch it nevertheless. + if len(systems) == 0: + raise SystemNrInvalidError(system_nr, "no systems returned by API") + + return systems[0] + + +class ProductNotFoundError(Exception): + def __init__(self, product, available_products): + self.product = product + self.available_products = available_products + + +def get_product(product_name, installation_nr, username): + query_path = f"SysProducts?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '' and Nocheck eq ''" + products = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + product = next((product for product in products if product['Description'] == product_name), None) + if product is None: + raise ProductNotFoundError(product_name, products) + + return product['Product'] + + +class VersionNotFoundError(Exception): + def __init__(self, version, available_versions): + self.version = version + self.available_versions = available_versions + + +def get_version(version_name, product_id, installation_nr, username): + query_path = f"SysVersions?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Product eq '{product_id}' and Nocheck eq ''" + versions = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + version = next((version for version in versions if version['Description'] == version_name), None) + if version is None: + raise VersionNotFoundError(version_name, versions) + + return version['Version'] + + +def validate_system_data(data, version_id, system_nr, installation_nr, username): + query_path = f"SystData?$filter=Pvnr eq '{version_id}' and Insnr eq '{installation_nr}'" + results = _request(_url(query_path), headers=_headers({})).json()['d']['results'][0] + possible_fields = json.loads(results['Output']) + final_fields = _validate_user_data_against_supported_fields("system", data, possible_fields) + + final_fields['Prodver'] = version_id + final_fields['Insnr'] = installation_nr + final_fields['Uname'] = username + final_fields['Sysnr'] = system_nr + final_fields = [{"name": k, "value": v} for k, v in final_fields.items()] + query_path = f"SystemDataCheck?$filter=Nocheck eq '' and Data eq '{json.dumps(final_fields)}'" + results = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + + warning = None + if len(results) > 0: + warning = json.loads(results[0]['Data'])[0]['VALUE'] + + # interestingly, all downstream api calls require the names in lowercase. transform it for further usage. + final_fields = [{"name": entry["name"].lower(), "value": entry["value"]} for entry in final_fields] + return final_fields, warning + + +class LicenseTypeInvalidError(Exception): + def __init__(self, license_type, available_license_types): + self.license_type = license_type + self.available_license_types = available_license_types + + +def validate_licenses(licenses, version_id, installation_nr, username): + query_path = f"LicenseType?$filter=PRODUCT eq '{version_id}' and INSNR eq '{installation_nr}' and Uname eq '{username}' and Nocheck eq 'True'" + results = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + + available_license_types = {result["LICENSETYPE"] for result in results} + license_data = [] + + for license in licenses: + result = next((result for result in results if result["LICENSETYPE"] == license['type']), None) + if result is None: + raise LicenseTypeInvalidError(license['type'], available_license_types) + + final_fields = _validate_user_data_against_supported_fields(f'license {license["type"]}', license['data'], + json.loads(result["Selfields"])) + # for some reason, the API wants to have the keys in uppercase, transform it + final_fields = {k.upper(): v for k, v in final_fields.items()} + final_fields["LICENSETYPE"] = result['PRODID'] + final_fields["LICENSETYPETEXT"] = result['LICENSETYPE'] + license_data.append(final_fields) + + return license_data + + +def get_existing_licenses(system_nr, username): + query_path = f"LicenseKeys?$filter=Uname eq '{username}' and Sysnr eq '{system_nr}'" + results = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + # for some weird reason that probably only SAP knows, when updating the licenses based on the results here, + # they expect a completely different format. let's transform to the format the backend expects. + # this code most likely doesn't work for licenses that have different parameters than S4HANA or SAP HANA + # (which only use HWKEY, EXPDATE and QUANTITY), as I only tested it with those two license types. + # feel free to extend (or, even better, come up with a generic way to transform the parameters). + return [ + { + "LICENSETYPETEXT": result["LicenseDescr"], + "LICENSETYPE": result["Prodid"], + "HWKEY": result["Hwkey"], + "EXPDATE": result["LidatC"], + "STATUS": result["Status"], + "STATUSCODE": result["StatusCode"], + "KEYNR": result["Keynr"], + "QUANTITY": result["Ulimit"], + "QUANTITY_C": result["UlimitC"], + "MAXEXPDATE": result["MaxLiDat"] + } for result in results + ] + + +def keep_only_new_or_changed_licenses(existing_licenses, license_data): + new_or_changed_licenses = [] + for license in license_data: + if not any(license['HWKEY'] == lic['HWKEY'] and license['LICENSETYPE'] == lic['LICENSETYPE'] for lic in + existing_licenses): + new_or_changed_licenses.append(license) + + return new_or_changed_licenses + + +def generate_licenses(license_data, existing_licenses, version_id, installation_nr, username): + body = { + "Prodver": version_id, + "ActionCode": "add", + "ExistingData": json.dumps(existing_licenses), + "Entry": json.dumps(license_data), + "Nocheck": "", + "Insnr": installation_nr, + "Uname": username + } + response = _request(_url("BSHWKEY"), json=body, headers=_headers({'x-csrf-token': _get_csrf_token()})).json() + return json.loads(response['d']['Result']) + + +def submit_system(is_new, system_data, generated_licenses, username): + body = { + "actcode": "add" if is_new else "edit", + "Uname": username, + "sysdata": json.dumps(system_data), + "matdata": json.dumps( + # again, SAP Backend requires a completely different format than it returned. let's map it. + # this code most likely doesn't work for licenses that have different parameters than S4HANA or SAP HANA + # (which only use HWKEY, EXPDATE and QUANTITY), as I only tested it with those two license types. + # feel free to extend (or, even better, come up with a generic way to transform the parameters). + [ + { + "hwkey": license["HWKEY"], + "prodid": license["LICENSETYPE"], + "quantity": license["QUANTITY"], + "keynr": license["KEYNR"], + "expdat": license["EXPDATE"], + "status": license["STATUS"], + "statusCode": license["STATUSCODE"], + } for license in generated_licenses + ] + ) + } + response = _request(_url("Submit"), json=body, headers=_headers({'x-csrf-token': _get_csrf_token()})).json() + return json.loads(response['d']['licdata'])[0]['VALUE'] # contains system number + + +def get_license_key_numbers(license_data, system_nr, username): + key_nrs = [] + for license in license_data: + query_path = f"LicenseKeys?$filter=Uname eq '{username}' and Sysnr eq '{system_nr}' and Prodid eq '{license['LICENSETYPE']}' and Hwkey eq '{license['HWKEY']}'" + results = _request(_url(query_path), headers=_headers({})).json()['d']['results'] + key_nrs.append(results[0]['Keynr']) + + return key_nrs + + +def download_licenses(key_nrs): + keys_json = json.dumps([{"Keynr": key_nr} for key_nr in key_nrs]) + return _request(_url(f"FileContent(Keynr='{keys_json}')/$value")).content + + +def find_licenses_to_delete(key_nrs_to_keep, existing_licenses): + return [existing_license for existing_license in existing_licenses if + not existing_license['KEYNR'] in key_nrs_to_keep] + + +def delete_licenses(licenses_to_delete, existing_licenses, version_id, installation_nr, username): + body = { + "Prodver": version_id, + "ActionCode": "delete", + "ExistingData": json.dumps(existing_licenses), + "Entry": json.dumps(licenses_to_delete), + "Nocheck": "", + "Insnr": installation_nr, + "Uname": username + } + response = _request(_url("BSHWKEY"), json=body, headers=_headers({'x-csrf-token': _get_csrf_token()})).json() + return json.loads(response['d']['Result']) + + +def _url(query_path): + return f'{C.URL_SYSTEMS_PROVISIONING}/{query_path}' + + +def _headers(additional_headers): + return {**additional_headers, **{'Accept': 'application/json'}} + + +def _get_csrf_token(): + return _request(C.URL_SYSTEMS_PROVISIONING, headers=_headers({'x-csrf-token': 'Fetch'})).headers['x-csrf-token'] + + +class DataInvalidError(Exception): + def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_invalid_option): + self.scope = scope + self.unknown_fields = unknown_fields + self.missing_required_fields = missing_required_fields + self.fields_with_invalid_option = fields_with_invalid_option + + +def _validate_user_data_against_supported_fields(scope, user_data, possible_fields): + unknown_fields = {field for field, _ in user_data.items() if + not any(field == possible_field['FIELD'] for possible_field in possible_fields)} + missing_required_fields = {} + fields_with_invalid_option = {} + final_fields = {} + + for possible_field in possible_fields: + user_value = user_data.get(possible_field["FIELD"]) + if user_value is not None: # user has provided a value for this field + if len(possible_field["DATA"]) == 0: # there are no options for these fields = all inputs are ok. + final_fields[possible_field["FIELD"]] = user_value + + else: # there are options for these fields - resolve their values by their description + resolved_value = next( + (entry["NAME"] for entry in possible_field["DATA"] if entry['VALUE'] == user_value), None) + if resolved_value is None: + fields_with_invalid_option[possible_field["FIELD"]] = possible_field["DATA"] + else: + final_fields[possible_field["FIELD"]] = resolved_value + elif possible_field['REQUIRED'] == "X": # missing required field + missing_required_fields[possible_field["FIELD"]] = possible_field["DATA"] + + if len(unknown_fields) > 0 or len(missing_required_fields) > 0 or len(fields_with_invalid_option) > 0: + raise DataInvalidError(scope, unknown_fields, missing_required_fields, fields_with_invalid_option) + + return final_fields diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py new file mode 100644 index 0000000..9d3efa4 --- /dev/null +++ b/plugins/modules/license_keys.py @@ -0,0 +1,143 @@ +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.sap_launchpad_systems_runner import * +from ..module_utils.sap_id_sso import sap_sso_login + + +def run_module(): + # Define available arguments/parameters a user can pass to the module + module_args = dict( + suser_id=dict(type='str', required=True), + suser_password=dict(type='str', required=True, no_log=True), + installation_nr=dict(type='str', required=True), + system=dict( + type='dict', + options=dict( + nr=dict(type='str', required=False), + product=dict(type='str', required=True), + version=dict(type='str', required=True), + data=dict(type='dict') + ) + ), + licenses=dict(type='list', required=True, elements='dict', options=dict( + type=dict(type='str', required=True), + data=dict(type='dict'), + )), + delete_other_licenses=dict(type='bool', required=False, default=False), + ) + + # Define result dictionary objects to be passed back to Ansible + result = dict( + license_file='', + changed=False, + msg='' + ) + + # Instantiate module + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=False + ) + + # Check mode + if module.check_mode: + module.exit_json(**result) + + username = module.params.get('suser_id') + password = module.params.get('suser_password') + installation_nr = module.params.get('installation_nr') + system = module.params.get('system') + system_nr = system.get('nr') + product = system.get('product') + version = system.get('version') + data = system.get('data') + licenses = module.params.get('licenses') + + if len(licenses) == 0: + module.fail_json("licenses cannot be empty") + + delete_other_licenses = module.params.get('delete_other_licenses') + + sap_sso_login(username, password) + + try: + validate_installation(installation_nr, username) + except InstallationNotFoundError as err: + module.fail_json("Installation could not be found", installation_nr=err.installation_nr, + available_installations=[inst['Text'] for inst in err.available_installations]) + + existing_system = None + if system_nr is not None: + try: + existing_system = get_system(system_nr, installation_nr, username) + except SystemNrInvalidError as err: + module.fail_json("System could not be found", system_nr=err.system_nr, details=err.details) + + product_id = None + try: + product_id = get_product(product, installation_nr, username) + except ProductNotFoundError as err: + module.fail_json("Product could not be found", product=err.product, + available_products=[product['Description'] for product in err.available_products]) + + version_id = None + try: + version_id = get_version(version, product_id, installation_nr, username) + except VersionNotFoundError as err: + module.fail_json("Version could not be found", version=err.version, + available_versions=[version['Description'] for version in err.available_versions]) + + system_data = None + try: + system_data, warning = validate_system_data(data, version_id, system_nr, installation_nr, username) + if warning is not None: + module.warn(warning) + except DataInvalidError as err: + module.fail_json(f"Invalid {err.scope} data", + unknown_fields=err.unknown_fields, + missing_required_fields=err.missing_required_fields, + fields_with_invalid_option=err.fields_with_invalid_option) + + license_data = None + try: + license_data = validate_licenses(licenses, version_id, installation_nr, username) + except LicenseTypeInvalidError as err: + module.fail_json(f"Invalid license type", license_type=err.license_type, available_license_types=err.available_license_types) + except DataInvalidError as err: + module.fail_json(f"Invalid {err.scope} data", + unknown_fields=err.unknown_fields, + missing_required_fields=err.missing_required_fields, + fields_with_invalid_option=err.fields_with_invalid_option) + + generated_licenses = [] + existing_licenses = [] + new_or_changed_license_data = license_data + + if existing_system is not None: + existing_licenses = get_existing_licenses(system_nr, username) + new_or_changed_license_data = keep_only_new_or_changed_licenses(existing_licenses, license_data) + + if len(new_or_changed_license_data) > 0: + generated_licenses = generate_licenses(new_or_changed_license_data, existing_licenses, version_id, + installation_nr, username) + + system_nr = submit_system(existing_system is None, system_data, generated_licenses, username) + key_nrs = get_license_key_numbers(license_data, system_nr, username) + result['license_file'] = download_licenses(key_nrs) + + if delete_other_licenses: + existing_licenses = get_existing_licenses(system_nr, username) + licenses_to_delete = find_licenses_to_delete(key_nrs, existing_licenses) + if len(licenses_to_delete) > 0: + updated_licenses = delete_licenses(licenses_to_delete, existing_licenses, version_id, installation_nr, username) + submit_system(False, system_data, updated_licenses, username) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py new file mode 100644 index 0000000..4732cc7 --- /dev/null +++ b/plugins/modules/systems_info.py @@ -0,0 +1,54 @@ +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.sap_launchpad_systems_runner import * +from ..module_utils.sap_id_sso import sap_sso_login + +from requests.exceptions import HTTPError + + +def run_module(): + + # Define available arguments/parameters a user can pass to the module + module_args = dict( + suser_id=dict(type='str', required=True), + suser_password=dict(type='str', required=True, no_log=True), + filter=dict(type='str', required=True), + ) + + # Define result dictionary objects to be passed back to Ansible + result = dict( + systems='', + changed=False, + ) + + # Instantiate module + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=False + ) + + # Check mode + if module.check_mode: + module.exit_json(**result) + + username = module.params.get('suser_id') + password = module.params.get('suser_password') + filter = module.params.get('filter') + + sap_sso_login(username, password) + + try: + result["systems"] = get_systems(filter) + except HTTPError as err: + module.fail_json("Error while querying systems", status_code=err.response.status_code, + response=err.response.content) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() From 8a80ce6ab7bb66b4050266cead2dabb630de5f61 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Mon, 30 Oct 2023 10:22:47 +0100 Subject: [PATCH 02/12] change order of passed headers so that default headers can be overwritten --- plugins/module_utils/sap_launchpad_systems_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/sap_launchpad_systems_runner.py b/plugins/module_utils/sap_launchpad_systems_runner.py index 5b1478d..8ad89d8 100644 --- a/plugins/module_utils/sap_launchpad_systems_runner.py +++ b/plugins/module_utils/sap_launchpad_systems_runner.py @@ -248,7 +248,7 @@ def _url(query_path): def _headers(additional_headers): - return {**additional_headers, **{'Accept': 'application/json'}} + return {**{'Accept': 'application/json'}, **additional_headers} def _get_csrf_token(): From 33c64ffdfc7365dbd450e0eabf4153d71dab1f69 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Mon, 30 Oct 2023 10:38:28 +0100 Subject: [PATCH 03/12] no need to explicitly check for diff mode if supports_check_mode=False --- plugins/modules/license_keys.py | 4 ---- plugins/modules/systems_info.py | 4 ---- 2 files changed, 8 deletions(-) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 9d3efa4..630ae64 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -39,10 +39,6 @@ def run_module(): supports_check_mode=False ) - # Check mode - if module.check_mode: - module.exit_json(**result) - username = module.params.get('suser_id') password = module.params.get('suser_password') installation_nr = module.params.get('installation_nr') diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index 4732cc7..69a3398 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -27,10 +27,6 @@ def run_module(): supports_check_mode=False ) - # Check mode - if module.check_mode: - module.exit_json(**result) - username = module.params.get('suser_id') password = module.params.get('suser_password') filter = module.params.get('filter') From e86395ccac0a9ac1b687251afe0229ce1e89cf31 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Mon, 30 Oct 2023 15:37:26 +0100 Subject: [PATCH 04/12] added code comments --- galaxy.yml | 2 +- .../sap_launchpad_systems_runner.py | 112 +++++++++++++++++- plugins/modules/license_keys.py | 14 ++- plugins/modules/systems_info.py | 1 + 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/galaxy.yml b/galaxy.yml index 6337958..2cac32e 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -8,7 +8,7 @@ namespace: community name: sap_launchpad # The version of the collection. Must be compatible with semantic versioning -version: 1.0.0 +version: 1.1.0 # The path to the Markdown (.md) readme file. This path is relative to the root of the collection readme: README.md diff --git a/plugins/module_utils/sap_launchpad_systems_runner.py b/plugins/module_utils/sap_launchpad_systems_runner.py index 8ad89d8..42ad82a 100644 --- a/plugins/module_utils/sap_launchpad_systems_runner.py +++ b/plugins/module_utils/sap_launchpad_systems_runner.py @@ -83,6 +83,47 @@ def get_version(version_name, product_id, installation_nr, username): def validate_system_data(data, version_id, system_nr, installation_nr, username): + """Validate that the user-provided system data (SID, OS, etc.) is valid according to the SAP API. + + In order to validate the data, the SAP API offers two endpoints: + - /SystData: returns the supported fields of a given product version and its supported values. Example: + { + "d": { + "results": [ + { + "__metadata": {...}, + ... + "Output": "[ + { ... + \"FIELD\":\"sysid\", + \"VALUE\":\"System ID\", + \"REQUIRED\":\"X\" + \"DATA\":[] + }, + ... + { ... + \"FIELD\":\"sysname\", + \"VALUE\":\"System Name\", + \"REQUIRED\":\"\", + }, + { ... + \"FIELD\":\"systype\", + \"VALUE\":\"System Type\", + \"REQUIRED\":\"X\", + \"DATA\": [ + {\"NAME\":\"ARCHIVE\",\"VALUE\":\"Archive System\"}, + {\"NAME\":\"BACKUP\",\"VALUE\":\"Backup system\"}, + {\"NAME\":\"DEMO\",\"VALUE\":\"Demo system\"}, + ... + ] + }, + So to ensure the user provided valid system data values, + we fetch these fields and ensure all the required fields are set and contain valid options. + + - Afterward, the validated data is sent to /SystemDataCheck to verify the data is accepted by the SAP API. + This endpoint might optionally return warnings (i.e. if the SID is used in more than one system), which are passed on to the user. + """ + query_path = f"SystData?$filter=Pvnr eq '{version_id}' and Insnr eq '{installation_nr}'" results = _request(_url(query_path), headers=_headers({})).json()['d']['results'][0] possible_fields = json.loads(results['Output']) @@ -112,6 +153,30 @@ def __init__(self, license_type, available_license_types): def validate_licenses(licenses, version_id, installation_nr, username): + """Validate that the user-provided licenses (license type and data like hardware key, expiry time) are valid + according to the SAP API. + + In order to validate the data, this function makes use of the /LicenseType API endpoint which provides the supported + license data for a given product version. Example for S4HANA2022: + { + "d": { + "results": [ + { + "__metadata": {...}, + "INSNR": "123456789", + "PRODUCT": "73554900100800000266", + "PRODID": "Maintenance", + "LICENSETYPE": "Maintenance Entitlement", + "QtyUnit": "", + "Selfields": "[ + {\"FIELD\":\"hwkey\",\"VALUE\":\"Hardware Key\",\"REQUIRED\":\"X\",\"DEFAULT\":\"\",\"DATA\":[], ...}, + {\"FIELD\":\"expdate\",\"VALUE\":\"Valid until\",\"REQUIRED\":\"X\",\"DEFAULT\":\"20240130\",\"DATA\":[], ...}]", + ... + + So to ensure the user provided valid license values, + we fetch these fields and ensure that the license type exists and all the required fields are set and contain valid options. + """ + query_path = f"LicenseType?$filter=PRODUCT eq '{version_id}' and INSNR eq '{installation_nr}' and Uname eq '{username}' and Nocheck eq 'True'" results = _request(_url(query_path), headers=_headers({})).json()['d']['results'] @@ -125,7 +190,7 @@ def validate_licenses(licenses, version_id, installation_nr, username): final_fields = _validate_user_data_against_supported_fields(f'license {license["type"]}', license['data'], json.loads(result["Selfields"])) - # for some reason, the API wants to have the keys in uppercase, transform it + # for some reason, the downstream API calls require the keys in uppercase - transform them. final_fields = {k.upper(): v for k, v in final_fields.items()} final_fields["LICENSETYPE"] = result['PRODID'] final_fields["LICENSETYPETEXT"] = result['LICENSETYPE'] @@ -159,6 +224,12 @@ def get_existing_licenses(system_nr, username): def keep_only_new_or_changed_licenses(existing_licenses, license_data): + """Given a system's licenses (existing_licenses) and the user-provided licenses (license_data), return only new or changed licenses. + + Why is this necessary? The SAP API Endpoint /BSHWKEY (in function generate_licenses) fails if an identical license + is generated twice - thus, this function removes identical licenses are removed from the user provided data. + """ + new_or_changed_licenses = [] for license in license_data: if not any(license['HWKEY'] == lic['HWKEY'] and license['LICENSETYPE'] == lic['LICENSETYPE'] for lic in @@ -224,7 +295,7 @@ def download_licenses(key_nrs): return _request(_url(f"FileContent(Keynr='{keys_json}')/$value")).content -def find_licenses_to_delete(key_nrs_to_keep, existing_licenses): +def select_licenses_to_delete(key_nrs_to_keep, existing_licenses): return [existing_license for existing_license in existing_licenses if not existing_license['KEYNR'] in key_nrs_to_keep] @@ -264,6 +335,43 @@ def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_i def _validate_user_data_against_supported_fields(scope, user_data, possible_fields): + """Validates user-provided data against all supported fields (provided by the SAP API). + + In various areas the SAP API provides which data attributes are supported for a given entity: + - i.e. for system data the supported fields are provided in /SystData (see function validate_system_data) + - i.e. for license data the supported fields are provided in /LicenseType (see function validate_licenses) + + The SAP API provides the supported fields in a common format: + { ... + \"FIELD\":\"free-text-field-name\", + \"REQUIRED\":\"X\" + \"DATA\":[] + }, + ... + { ... + \"FIELD\":\"optional-field-name\", + \"REQUIRED\":\"\", + \"DATA\":[] + }, + { ... + \"FIELD\":field-with-predefined-options\", + \"REQUIRED\":\"X\", + \"DATA\": [ + {\"NAME\":\"OPTION1\",\"VALUE\":\"Description of Option1\"}, + {\"NAME\":\"OPTION2\",\"VALUE\":\"Description of Option2\"}, + {\"NAME\":\"OPTION3\",\"VALUE\":\"Description of Option3\"}, + ... + ] + } + + This helper method uses those fields provided by the SAP API and the user-provided data and raises a DataInvalidError + if any of the following issues is detected + - DataInvalidError.missing_fields: a required field (= REQUIRED = 'X') is not provided by the user + - DataInvalidError.fields_with_invalid_option: the user specified a invalid option for a field which has defined options + - DataInvalidError.unknown_fields: user provided a field which is not supported by SAP API + + """ + unknown_fields = {field for field, _ in user_data.items() if not any(field == possible_field['FIELD'] for possible_field in possible_fields)} missing_required_fields = {} diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 630ae64..eb26775 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -4,6 +4,8 @@ from ..module_utils.sap_id_sso import sap_sso_login +# TODO document + def run_module(): # Define available arguments/parameters a user can pass to the module module_args = dict( @@ -56,6 +58,16 @@ def run_module(): sap_sso_login(username, password) + # This module closely mimics the flow of the portal (me.sap.com/licensekey) when creating license keys: + # - validate the user-provided installation against the available installations from API call /Installations + # - validate the user-provided product against the available products from API call /SysProducts + # - validate the user-provided product against the available product versions from API call /SysVersions + # - validate the user-provided system data (SID, OS etc.) via API calls /SystData and /SystemDataCheck + # - validate the user-provided license type and data via API call /LicenseType + # - if the validation succeeds, the data is enriched with the existing system and license data and submitted + # by first generating the licenses via API Call /BSHWKEY and then submitting the system via API call /Submit. + # - as a last step, the license keys are now downloaded via API call /FileContent. + try: validate_installation(installation_nr, username) except InstallationNotFoundError as err: @@ -123,7 +135,7 @@ def run_module(): if delete_other_licenses: existing_licenses = get_existing_licenses(system_nr, username) - licenses_to_delete = find_licenses_to_delete(key_nrs, existing_licenses) + licenses_to_delete = select_licenses_to_delete(key_nrs, existing_licenses) if len(licenses_to_delete) > 0: updated_licenses = delete_licenses(licenses_to_delete, existing_licenses, version_id, installation_nr, username) submit_system(False, system_data, updated_licenses, username) diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index 69a3398..568d2dd 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -5,6 +5,7 @@ from requests.exceptions import HTTPError +# TODO document def run_module(): From bac0c4e6d9a9aad557d0745fdd4d86fbe3fa9608 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Tue, 31 Oct 2023 19:18:18 +0100 Subject: [PATCH 05/12] added documentation --- plugins/modules/license_keys.py | 146 +++++++++++++++++++++++++++++++- plugins/modules/systems_info.py | 58 +++++++++++-- 2 files changed, 195 insertions(+), 9 deletions(-) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index eb26775..5ba2896 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -4,7 +4,144 @@ from ..module_utils.sap_id_sso import sap_sso_login -# TODO document +DOCUMENTATION = r''' +--- +module: license_keys + +short_description: Creates systems and license keys on me.sap.com/licensekey + +version_added: 1.1.0 + +options: + suser_id: + description: + - SAP S-User ID. + required: true + type: str + suser_password: + description: + - SAP S-User Password. + required: true + type: str + installation_nr: + description: + - Number of the Installation for which the system should be created/updated + required: true + type: str + system: + description: + - The system to create/update + required: true + type: dict + suboptions: + nr: + description: + - The number of the system to update. If this attribute is not provided, a new system is created. + required: false + type: str + product: + description: + - The product description as found in the SAP portal, e.g. SAP S/4HANA + required: true + type: str + version: + description: + - The description of the product version, as found in the SAP portal, e.g. SAP S/4HANA 2022 + required: true + type: str + data: + description: + - The data attributes of the system. The possible attributes are defined by product and version. + - Running the module without any data attributes will return in the error message which attributes are supported/required. + required: true + type: dict + + licenses: + description: + - List of licenses to create for the system. + - If the license does not exist, it is created. + - If it exists, it is updated. + required: true + type: list + elements: dict + suboptions: + type: + description: + - The license type description as found in the SAP portal, e.g. Maintenance Entitlement + required: true + type: str + data: + description: + - The data attributes of the licenses. The possible attributes are defined by product and version. + - Running the module without any data attributes will return in the error message which attributes are supported/required + - In practice, most license types require at least a hardware key (hwkey) and expiry date (expdate) + required: true + type: dict + + delete_other_licenses: + description: + - Whether licenses other than the ones specified in the licenses attributes should be deleted. + - This is handy to clean up older licenses automatically. + type: bool + required: false + default: false + + +author: + - Lab for SAP Solutions + +''' + + +EXAMPLES = r''' +- name: create license keys + community.sap_launchpad.license_keys: + suser_id: 'SXXXXXXXX' + suser_password: 'password' + installation_nr: 12345678 + system: + nr: 12345678 + product: SAP S/4HANA + version: SAP S/4HANA 2022 + data: + sysid: H01 + sysname: Test-System + systype: Development system + sysdb: SAP HANA database + sysos: Linux + sys_depl: Public - Microsoft Azure + licenses: + - type: Standard - Web Application Server ABAP or ABAP+JAVA + data: + hwkey: H1234567890 + expdate: 99991231 + - type: Maintenance Entitlement + data: + hwkey: H1234567890 + expdate: 99991231 + delete_other_licenses: true + register: result + +- name: Display the license file containing the licenses + debug: + msg: + - "{{ result.license_file }}" +''' + + +RETURN = r''' +license_file: + description: | + The license file containing the digital signatures of the specified licenses. + All licenses that were provided in the licenses attribute are returned, no matter if they were modified or not. + returned: always + type: string +system_nr: + description: The number of the system which was created/updated. + returned: always + type: string +''' + def run_module(): # Define available arguments/parameters a user can pass to the module @@ -31,8 +168,10 @@ def run_module(): # Define result dictionary objects to be passed back to Ansible result = dict( license_file='', - changed=False, - msg='' + system_nr='', + # as we don't have a diff mechanism but always submit the system, we don't have a way to detect changes. + # it might always have changed. + changed=True, ) # Instantiate module @@ -132,6 +271,7 @@ def run_module(): system_nr = submit_system(existing_system is None, system_data, generated_licenses, username) key_nrs = get_license_key_numbers(license_data, system_nr, username) result['license_file'] = download_licenses(key_nrs) + result['system_nr'] = system_nr if delete_other_licenses: existing_licenses = get_existing_licenses(system_nr, username) diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index 568d2dd..425a51d 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -5,24 +5,70 @@ from requests.exceptions import HTTPError -# TODO document +DOCUMENTATION = r''' +--- +module: systems_info -def run_module(): +short_description: Queries registered systems in me.sap.com + +version_added: 1.1.0 + +options: + suser_id: + description: + - SAP S-User ID. + required: true + type: str + suser_password: + description: + - SAP S-User Password. + required: true + type: str + filter: + description: + - An ODATA filter expression to query the systems. + required: true + type: str +author: + - Lab for SAP Solutions + +''' + + +EXAMPLES = r''' +- name: get system by SID and product + community.sap_launchpad.systems_info: + suser_id: 'SXXXXXXXX' + suser_password: 'password' + filter: "Insnr eq '12345678' and sysid eq 'H01' and ProductDescr eq 'SAP S/4HANA'" + register: result - # Define available arguments/parameters a user can pass to the module +- name: Display the first returned system + debug: + msg: + - "{{ result.systems[0] }}" +''' + + +RETURN = r''' +systems: + description: the systems returned for the filter + returned: always + type: list +''' + + +def run_module(): module_args = dict( suser_id=dict(type='str', required=True), suser_password=dict(type='str', required=True, no_log=True), filter=dict(type='str', required=True), ) - # Define result dictionary objects to be passed back to Ansible result = dict( systems='', - changed=False, ) - # Instantiate module module = AnsibleModule( argument_spec=module_args, supports_check_mode=False From d0cb4f5dcf39941a30d408c5750756bfb6b672c7 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Wed, 8 Nov 2023 13:55:29 +0100 Subject: [PATCH 06/12] revert to 1.0.0 --- galaxy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galaxy.yml b/galaxy.yml index 2cac32e..6337958 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -8,7 +8,7 @@ namespace: community name: sap_launchpad # The version of the collection. Must be compatible with semantic versioning -version: 1.1.0 +version: 1.0.0 # The path to the Markdown (.md) readme file. This path is relative to the root of the collection readme: README.md From 390be925d3456d5c5477cdaf5b6941918e515e0c Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Wed, 8 Nov 2023 13:56:40 +0100 Subject: [PATCH 07/12] make system nr different from installation_nr --- plugins/modules/license_keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 5ba2896..fd12926 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -100,7 +100,7 @@ suser_password: 'password' installation_nr: 12345678 system: - nr: 12345678 + nr: 23456789 product: SAP S/4HANA version: SAP S/4HANA 2022 data: From c654e625c349c9c97538c347d8f047207a42e328 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Wed, 8 Nov 2023 14:03:45 +0100 Subject: [PATCH 08/12] add sample output for return vals --- plugins/modules/license_keys.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index fd12926..5d1bb6d 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -136,10 +136,33 @@ All licenses that were provided in the licenses attribute are returned, no matter if they were modified or not. returned: always type: string + sample: | + ----- Begin SAP License ----- + SAPSYSTEM=H01 + HARDWARE-KEY=H1234567890 + INSTNO=0012345678 + BEGIN=20231026 + EXPIRATION=99991231 + LKEY=MIIBO... + SWPRODUCTNAME=NetWeaver_MYS + SWPRODUCTLIMIT=2147483647 + SYSTEM-NR=00000000023456789 + ----- Begin SAP License ----- + SAPSYSTEM=H01 + HARDWARE-KEY=H1234567890 + INSTNO=0012345678 + BEGIN=20231026 + EXPIRATION=20240127 + LKEY=MIIBO... + SWPRODUCTNAME=Maintenance_MYS + SWPRODUCTLIMIT=2147483647 + SYSTEM-NR=00000000023456789 + system_nr: description: The number of the system which was created/updated. returned: always type: string + sample: 23456789 ''' From 28b35c61bd67d25c54b021f39e6450b7ce6b9b50 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Wed, 8 Nov 2023 14:49:38 +0100 Subject: [PATCH 09/12] add long description for license keys module --- plugins/modules/license_keys.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 5d1bb6d..a3df0f5 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -10,6 +10,14 @@ short_description: Creates systems and license keys on me.sap.com/licensekey +description: + - This ansible module creates and updates systems and their license keys using the Launchpad API. + - It is closely modeled after the interactions in the portal https://me.sap.com/licensekey: + - First, a SAP system is defined by its SID, product, version and other data. + - Then, for this system, license keys are defined by license type, HW key and potential other attributes. + - The system and license data is then validated and submitted to the Launchpad API and the license key files returned to the caller. + - This module attempts to be as idempotent as possible, so it can be used in a CI/CD pipeline. + version_added: 1.1.0 options: From f25bc798b5249e5ef49822f0bf509529d7f4ba80 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Sun, 12 Nov 2023 15:29:30 +0100 Subject: [PATCH 10/12] add markup for link in description Co-authored-by: Rainer Leber <39616583+rainerleber@users.noreply.github.com> --- plugins/modules/license_keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index a3df0f5..009d30e 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -12,7 +12,7 @@ description: - This ansible module creates and updates systems and their license keys using the Launchpad API. - - It is closely modeled after the interactions in the portal https://me.sap.com/licensekey: + - It is closely modeled after the interactions in the portal U(https://me.sap.com/licensekey): - First, a SAP system is defined by its SID, product, version and other data. - Then, for this system, license keys are defined by license type, HW key and potential other attributes. - The system and license data is then validated and submitted to the Launchpad API and the license key files returned to the caller. From c2ec65d3cff52b6d08b9b13eafc31ba6458fd260 Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Sun, 12 Nov 2023 15:30:02 +0100 Subject: [PATCH 11/12] add long description for systems info module Co-authored-by: Rainer Leber <39616583+rainerleber@users.noreply.github.com> --- plugins/modules/systems_info.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index 425a51d..6c60003 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -11,6 +11,10 @@ short_description: Queries registered systems in me.sap.com +description: +- Fetch Systems from U(me.sap.com) with ODATA query filtering and returns the discovered Systems. +- The query could easily copied from U(https://launchpad.support.sap.com/services/odata/i7p/odata/bkey) + version_added: 1.1.0 options: From 7db5fd422a3297cd60634b4590fa8def9f409dce Mon Sep 17 00:00:00 2001 From: Matthias Winzeler Date: Sun, 12 Nov 2023 15:30:16 +0100 Subject: [PATCH 12/12] remove redundant description Co-authored-by: Rainer Leber <39616583+rainerleber@users.noreply.github.com> --- plugins/modules/license_keys.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 009d30e..ce25cd4 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -228,15 +228,6 @@ def run_module(): sap_sso_login(username, password) - # This module closely mimics the flow of the portal (me.sap.com/licensekey) when creating license keys: - # - validate the user-provided installation against the available installations from API call /Installations - # - validate the user-provided product against the available products from API call /SysProducts - # - validate the user-provided product against the available product versions from API call /SysVersions - # - validate the user-provided system data (SID, OS etc.) via API calls /SystData and /SystemDataCheck - # - validate the user-provided license type and data via API call /LicenseType - # - if the validation succeeds, the data is enriched with the existing system and license data and submitted - # by first generating the licenses via API Call /BSHWKEY and then submitting the system via API call /Submit. - # - as a last step, the license keys are now downloaded via API call /FileContent. try: validate_installation(installation_nr, username)