diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 59a1216..5b74e21 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -6,8 +6,9 @@ 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' -# Maintainance Planner -URL_MAINTAINANCE_PLANNER = 'https://maintenanceplanner.cfapps.eu10.hana.ondemand.com' +# Maintenance Planner +URL_MAINTENANCE_PLANNER = 'https://maintenanceplanner.cfapps.eu10.hana.ondemand.com' +URL_SYSTEMS_PROVISIONING = 'https://launchpad.support.sap.com/services/odata/i7p/odata/bkey' URL_USERAPPS = 'https://userapps.support.sap.com/sap/support/mp/index.html' URL_USERAPP_MP_SERVICE = 'https://userapps.support.sap.com/sap/support/mnp/services' URL_LEGACY_MP_API = 'https://tech.support.sap.com/sap/support/mnp/services' diff --git a/plugins/module_utils/sap_api_common.py b/plugins/module_utils/sap_api_common.py index bc428dd..8e55032 100644 --- a/plugins/module_utils/sap_api_common.py +++ b/plugins/module_utils/sap_api_common.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python3 -# coding: utf-8 - import logging import re from urllib.parse import urlparse @@ -37,7 +34,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_id_sso.py b/plugins/module_utils/sap_id_sso.py index 577ccc7..406ee8c 100644 --- a/plugins/module_utils/sap_id_sso.py +++ b/plugins/module_utils/sap_id_sso.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python3 -# coding: utf-8 - import json import logging import re diff --git a/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py b/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py index 00538c0..bae7495 100644 --- a/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py +++ b/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py @@ -1,7 +1,3 @@ -#!/user/bin/env python3 -# coding: utf-8 - -import os import pathlib import re import time @@ -11,11 +7,10 @@ from bs4 import BeautifulSoup from lxml import etree from requests.auth import HTTPBasicAuth -from requests.sessions import session from . import constants as C from .sap_api_common import _request, https_session -from .sap_id_sso import _get_sso_endpoint_meta, sap_sso_login +from .sap_id_sso import _get_sso_endpoint_meta _MP_XSRF_TOKEN = None _MP_TRANSACTIONS = None @@ -24,7 +19,7 @@ def auth_maintenance_planner(): # Clear mp relevant cookies for avoiding unexpected responses. _clear_mp_cookies('maintenanceplanner') - res = _request(C.URL_MAINTAINANCE_PLANNER) + res = _request(C.URL_MAINTENANCE_PLANNER) sig_re = re.compile('signature=(.*?);path=\/";location="(.*)"') signature, redirect = re.search(sig_re, res.text).groups() @@ -35,7 +30,7 @@ def auth_maintenance_planner(): 'locationAfterLogin': '%2F' } - MP_DOMAIN = C.URL_MAINTAINANCE_PLANNER.replace('https://', '') + MP_DOMAIN = C.URL_MAINTENANCE_PLANNER.replace('https://', '') for k, v in mp_cookies.items(): https_session.cookies.set(k, v, domain=MP_DOMAIN, path='/') @@ -68,7 +63,7 @@ def auth_userapps(): def get_mp_user_details(): - url = urljoin(C.URL_MAINTAINANCE_PLANNER, + url = urljoin(C.URL_MAINTENANCE_PLANNER, '/MCP/MPHomePageController/getUserDetailsDisplay') params = {'_': int(time.time() * 1000)} user = _request(url, params=params).json() diff --git a/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py b/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py index d582d5d..255e0e4 100644 --- a/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py +++ b/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py @@ -1,9 +1,5 @@ -#!/user/bin/env python3 -# coding: utf-8 - from . import constants as C from .sap_api_common import _request -from .sap_id_sso import sap_sso_login def get_software_catalog(): diff --git a/plugins/module_utils/sap_launchpad_software_center_download_runner.py b/plugins/module_utils/sap_launchpad_software_center_download_runner.py index aa45d95..86cc55b 100644 --- a/plugins/module_utils/sap_launchpad_software_center_download_runner.py +++ b/plugins/module_utils/sap_launchpad_software_center_download_runner.py @@ -1,6 +1,3 @@ -#!/user/bin/env python3 -# coding: utf-8 - import hashlib import json import logging @@ -12,7 +9,7 @@ from . import constants as C from .sap_api_common import _request, https_session -from .sap_id_sso import _get_sso_endpoint_meta, sap_sso_login +from .sap_id_sso import _get_sso_endpoint_meta logger = logging.getLogger(__name__) diff --git a/plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py b/plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py index ff75952..98be35f 100644 --- a/plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py +++ b/plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py @@ -5,7 +5,6 @@ from . import constants as C from .sap_api_common import _request -from .sap_id_sso import sap_sso_login def search_software_fuzzy(query, max=None, csv_filename=None): 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..42ad82a --- /dev/null +++ b/plugins/module_utils/sap_launchpad_systems_runner.py @@ -0,0 +1,400 @@ +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): + """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']) + 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): + """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'] + + 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 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'] + 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): + """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 + 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 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] + + +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 {**{'Accept': 'application/json'}, **additional_headers} + + +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): + """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 = {} + 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..ce25cd4 --- /dev/null +++ b/plugins/modules/license_keys.py @@ -0,0 +1,313 @@ +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 + + +DOCUMENTATION = r''' +--- +module: license_keys + +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 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. + - 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: + 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: 23456789 + 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 + 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 +''' + + +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='', + 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 + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=False + ) + + 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) + result['system_nr'] = system_nr + + if delete_other_licenses: + existing_licenses = get_existing_licenses(system_nr, username) + 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) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index 248779d..5735920 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # SAP Maintenance Planner files retrieval @@ -69,6 +69,7 @@ from ..module_utils.sap_launchpad_maintenance_planner_runner import * from ..module_utils.sap_launchpad_software_center_download_runner import \ is_download_link_available +from ..module_utils.sap_id_sso import sap_sso_login def run_module(): diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index 30ec612..e7a5182 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # SAP Maintenance Planner Stack XML download @@ -69,6 +69,7 @@ # Import runner from ..module_utils.sap_launchpad_maintenance_planner_runner import * +from ..module_utils.sap_id_sso import sap_sso_login def run_module(): diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index 0cc8ca9..9278572 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # SAP software download module @@ -88,6 +88,7 @@ # Import runner from ..module_utils.sap_launchpad_software_center_download_runner import * +from ..module_utils.sap_id_sso import sap_sso_login def run_module(): diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py new file mode 100644 index 0000000..6c60003 --- /dev/null +++ b/plugins/modules/systems_info.py @@ -0,0 +1,101 @@ +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 + +DOCUMENTATION = r''' +--- +module: systems_info + +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: + 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 + +- 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), + ) + + result = dict( + systems='', + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=False + ) + + 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()