From f11ee66609d3a6266518a0b537ace2e6aa34315a Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Fri, 5 Sep 2025 17:07:42 +0200 Subject: [PATCH 1/9] software_center_download refactor --- plugins/module_utils/README.md | 3 - plugins/module_utils/__init__.py | 0 plugins/module_utils/auth.py | 258 ++++++++++++++ plugins/module_utils/client.py | 117 +++++++ plugins/module_utils/constants.py | 17 +- plugins/module_utils/exceptions.py | 27 ++ plugins/module_utils/sap_api_common.py | 114 ------ plugins/module_utils/sap_id_sso.py | 252 -------------- ...unchpad_software_center_download_runner.py | 327 ------------------ ...d_software_center_download_search_fuzzy.py | 323 ----------------- .../module_utils/software_center/__init__.py | 1 + .../module_utils/software_center/download.py | 189 ++++++++++ plugins/module_utils/software_center/main.py | 153 ++++++++ .../module_utils/software_center/search.py | 301 ++++++++++++++++ plugins/modules/README.md | 3 - plugins/modules/software_center_download.py | 202 ++--------- 16 files changed, 1081 insertions(+), 1206 deletions(-) delete mode 100644 plugins/module_utils/README.md create mode 100644 plugins/module_utils/__init__.py create mode 100644 plugins/module_utils/auth.py create mode 100644 plugins/module_utils/client.py create mode 100644 plugins/module_utils/exceptions.py delete mode 100644 plugins/module_utils/sap_api_common.py delete mode 100644 plugins/module_utils/sap_id_sso.py delete mode 100644 plugins/module_utils/sap_launchpad_software_center_download_runner.py delete mode 100644 plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py create mode 100644 plugins/module_utils/software_center/__init__.py create mode 100644 plugins/module_utils/software_center/download.py create mode 100644 plugins/module_utils/software_center/main.py create mode 100644 plugins/module_utils/software_center/search.py delete mode 100644 plugins/modules/README.md diff --git a/plugins/module_utils/README.md b/plugins/module_utils/README.md deleted file mode 100644 index 4f1fc6d..0000000 --- a/plugins/module_utils/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Scripts for Ansible Modules documentation - -Each Ansible Module has documentation underneath `/docs`, which contains any referring documentation regarding usage of Python Functions, Bash Functions etc. diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py new file mode 100644 index 0000000..3d52bb3 --- /dev/null +++ b/plugins/module_utils/auth.py @@ -0,0 +1,258 @@ +import json +import re + +from urllib.parse import parse_qs, quote_plus, urljoin +from bs4 import BeautifulSoup +from requests.models import HTTPError + +from . import constants as C +from . import exceptions + +_GIGYA_SDK_BUILD_NUMBER = None + + +def login(client, username, password): + # Main authentication function. + # + # This function orchestrates the entire SAP SSO and Gigya authentication + # flow. It accepts an ApiClient instance, which it populates with the + # necessary session cookies upon successful authentication. + client.session.cookies.clear() + + # Ensure usage of SAP User ID even when SAP Universal ID is used, + # login with email address of SAP Universal ID will otherwise + # incorrectly default to the last used SAP User ID + if not re.match(r'^[sS]\d+$', username): + raise ValueError('Please login with SAP User ID (like `S1234567890`)') + + endpoint = C.URL_LAUNCHPAD + meta = {} + + while ('SAMLResponse' not in meta and 'login_hint' not in meta): + endpoint, meta = _get_sso_endpoint_meta(client, endpoint, data=meta) + if 'j_username' in meta: + meta['j_username'] = username + meta['j_password'] = password + if 'changePassword' in endpoint: + raise ValueError('SAP ID Service has requested `Change Your Password`, possibly the password is too old. Please reset manually and try again.') + + if 'authn' in endpoint: + support_endpoint, support_meta = _get_sso_endpoint_meta(client, endpoint, data=meta) + client.post(support_endpoint, data=support_meta) + + if 'gigya' in endpoint: + params = _get_gigya_login_params(client, endpoint, data=meta) + _gigya_websdk_bootstrap(client, params) + login_token = _gigya_login(client, username, password, params['apiKey']) + + uid = _get_uid(client, params, login_token) + id_token = _get_id_token(client, params, login_token) + uid_details = _get_uid_details(client, uid, id_token) + if _is_uid_linked_multiple_sids(uid_details): + _select_account(client, uid, username, id_token) + + idp_endpoint = C.URL_ACCOUNT_SSO_IDP.format(k=params['apiKey']) + context = { + 'loginToken': login_token, + 'samlContext': params['samlContext'] + } + endpoint, meta = _get_sso_endpoint_meta(client, idp_endpoint, + params=context, + allow_redirects=False) + + while (endpoint != C.URL_LAUNCHPAD + '/'): + endpoint, meta = _get_sso_endpoint_meta(client, endpoint, + data=meta, + headers=C.GIGYA_HEADERS, + allow_redirects=False) + + client.post(endpoint, data=meta, headers=C.GIGYA_HEADERS) + + +def _get_sso_endpoint_meta(client, url, **kwargs): + # Scrapes an HTML page to find the next SSO form action URL and its input fields. + method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET' + res = client.request(method, url, **kwargs) + soup = BeautifulSoup(res.content, features='lxml') + + # SSO returns 200 OK even when the crendential is wrong, so we need to + # detect the HTTP body for auth error message. This is only necessary + # for non-universal SID. For universal SID, the client will raise 401 + # during Gygia auth. + error_message = soup.find('div', {'id': 'globalMessages'}) + if error_message and 'we could not authenticate you' in error_message.text: + res.status_code = 401 + res.reason = 'Unauthorized' + res.raise_for_status() + + form = soup.find('form') + if not form: + raise ValueError( + f'Unable to find form: {res.url}\nContent:\n{res.text}') + inputs = form.find_all('input') + + endpoint = urljoin(res.url, form['action']) + metadata = { + i.get('name'): i.get('value') + for i in inputs if i.get('type') != 'submit' and i.get('name') + } + + return (endpoint, metadata) + + +def _get_gigya_login_params(client, url, data): + # Follows a redirect and extracts parameters from the resulting URL's query string. + gigya_idp_res = client.post(url, data=data) + + extracted_url_params = re.sub(r'^.*?\?', '', gigya_idp_res.url) + params = {k: v[0] for k, v in parse_qs(extracted_url_params).items()} + return params + + +def _gigya_websdk_bootstrap(client, params): + # Performs the initial bootstrap call to the Gigya WebSDK. + page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'], + params.update({ + 'pageURL': page_url, + 'sdk': 'js_latest', + 'sdkBuild': '12426', + 'format': 'json', + }) + + client.get(C.URL_ACCOUNT_CDC_API + '/accounts.webSdkBootstrap', + params=params, + headers=C.GIGYA_HEADERS) + + +def _gigya_login(client, username, password, api_key): + # Performs a login using the standard Gigya accounts.login API. + # This avoids a custom SAP endpoint that triggers password change notifications. + login_payload = { + 'loginID': username, + 'password': password, + 'apiKey': api_key, + 'sessionExpiration': 0, + 'include': 'login_token' + } + + login_url = f"{C.URL_ACCOUNT_CDC_API}/accounts.login" + res = client.post(login_url, data=login_payload) + login_response = res.json() + + # Explicitly check for API errors, especially for password-related issues. + error_code = login_response.get('errorCode', 0) + if error_code != 0: + # Error 206002 indicates that the account is pending a password reset. + if error_code == 206002: + raise exceptions.AuthenticationError( + 'The password for this account has expired or must be changed. ' + 'Please log in to https://account.sap.com manually to reset it.' + ) + error_message = login_response.get('errorDetails', 'Unknown authentication error') + raise exceptions.AuthenticationError(f"Gigya authentication failed: {error_message} (errorCode: {error_code})") + + return login_response.get('login_token') + + +def _get_id_token(client, saml_params, login_token): + # Exchanges a Gigya login token for a JWT ID token. + query_params = { + 'expiration': '180', + 'login_token': login_token, + } + + jwt_response = _cdc_api_request(client, 'accounts.getJWT', saml_params, query_params) + token = jwt_response.get('id_token') + return token + + +def _get_uid(client, saml_params, login_token): + # Retrieves the user's unique ID (UID) using the login token. + query_params = { + 'include': 'profile,data', + 'login_token': login_token, + } + account_info_response = _cdc_api_request(client, 'accounts.getAccountInfo', saml_params, query_params) + uid = account_info_response.get('UID') + return uid + + +def _get_uid_details(client, uid, id_token): + # Fetches detailed account information for a given UID. + url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}' + headers = C.GIGYA_HEADERS.copy() + headers['Authorization'] = f'Bearer {id_token}' + + uid_details_response = client.get(url, headers=headers).json() + return uid_details_response + + +def _is_uid_linked_multiple_sids(uid_details): + # Checks if a Universal ID (UID) is linked to more than one S-User ID. + accounts = uid_details['accounts'] + linked = [] + for _, v in accounts.items(): + linked.extend(v['linkedAccounts']) + + return len(linked) > 1 + + +def _select_account(client, uid, sid, id_token): + # Selects a specific S-User ID when a Universal ID is linked to multiple accounts. + url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}/selectedAccount' + data = {'idsName': sid, 'automatic': 'false'} + + headers = C.GIGYA_HEADERS.copy() + headers['Authorization'] = f'Bearer {id_token}' + return client.request('PUT', url, headers=headers, json=data) + + +def _get_sdk_build_number(client, api_key): + # Fetches the gigya.js file to extract and cache the SDK build number. + global _GIGYA_SDK_BUILD_NUMBER + if _GIGYA_SDK_BUILD_NUMBER is not None: + return _GIGYA_SDK_BUILD_NUMBER + + res = client.get('https://cdns.gigya.com/js/gigya.js', + params={'apiKey': api_key}) + gigya_js_content = res.text + match = re.search(r'gigya.build\s*=\s*{[\s\S]+"number"\s*:\s*(\d+),', gigya_js_content) + if not match: + raise HTTPError("unable to find gigya sdk build number", res.response) + + build_number = match.group(1) + _GIGYA_SDK_BUILD_NUMBER = build_number + return build_number + + +def _cdc_api_request(client, endpoint, saml_params, query_params): + # Helper to make requests to the Gigya/CDC API, handling common parameters and errors. + url = '/'.join((C.URL_ACCOUNT_CDC_API, endpoint)) + + query = '&'.join([f'{k}={v}' for k, v in saml_params.items()]) + page_url = quote_plus('?'.join((C.URL_ACCOUNT_SAML_PROXY, query))) + + api_key = saml_params['apiKey'] + sdk_build = _get_sdk_build_number(client, api_key) + + params = { + 'sdk': 'js_latest', + 'APIKey': api_key, + 'authMode': 'cookie', + 'pageURL': page_url, + 'sdkBuild': sdk_build, + 'format': 'json' + } + + if query_params: + params.update(query_params) + + res = client.get(url, params=params, headers=C.GIGYA_HEADERS) + json_response = json.loads(res.text) + + error_code = json_response['errorCode'] + if error_code != 0: + http_error_msg = '{} Error: {} for url: {}'.format( + json_response['statusCode'], json_response['errorMessage'], res.url) + raise HTTPError(http_error_msg, response=res) + return json_response diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py new file mode 100644 index 0000000..9bd19e1 --- /dev/null +++ b/plugins/module_utils/client.py @@ -0,0 +1,117 @@ +import requests +import re +import urllib3 + +from urllib.parse import urlparse +from requests.adapters import HTTPAdapter + +from .constants import COMMON_HEADERS + + +class _SessionAllowBasicAuthRedirects(requests.Session): + # By default, the `Authorization` header for Basic Auth will be removed + # if the redirect is to a different host. + # In our case, the DirectDownloadLink with `softwaredownloads.sap.com` domain + # will be redirected to `origin.softwaredownloads.sap.com`, + # so we need to override `rebuild_auth` to perseve the Authorization header + # for sap.com domains. + # This is only required for legacy API. + def rebuild_auth(self, prepared_request, response): + if 'Authorization' in prepared_request.headers: + request_hostname = urlparse(prepared_request.url).hostname + if not re.match(r'.*sap.com$', request_hostname): + del prepared_request.headers['Authorization'] + +def _is_updated_urllib3(): + # `method_whitelist` argument for Retry is deprecated since 1.26.0, + # and will be removed in v2.0.0. + # Typically, the default version on RedHat 8.2 is 1.24.2, + # so we need to check the version of urllib3 to see if it's updated. + urllib3_version = urllib3.__version__.split('.') + if len(urllib3_version) == 2: + urllib3_version.append('0') + major, minor, patch = urllib3_version + major, minor, patch = int(major), int(minor), int(patch) + return (major, minor, patch) >= (1, 26, 0) + + +class ApiClient: + # A client for handling all HTTP communication with SAP APIs. + # + # This class encapsulates a requests.Session object, configured with + # automatic retries and custom header handling. It provides a clean, + # object-oriented interface for making API requests, replacing the + # previous global session and request functions. + def __init__(self): + self.session = _SessionAllowBasicAuthRedirects() + + # Configure retry logic for the session. + retries = urllib3.Retry( + connect=3, + read=3, + status=3, + status_forcelist=[413, 429, 500, 502, 503, 504, 509], + backoff_factor=1 + ) + + # Set allowed methods for retries, handling different urllib3 versions. + allowed_methods = frozenset( + ['HEAD', 'GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'TRACE'] + ) + if _is_updated_urllib3(): + retries.allowed_methods = allowed_methods + else: + retries.method_whitelist = allowed_methods + + # Mount the adapter to the session. + adapter = HTTPAdapter(max_retries=retries) + self.session.mount('https://', adapter) + self.session.mount('http://', adapter) + + def request(self, method, url, **kwargs): + # Makes an HTTP request. + # + # This method is a wrapper around the session's request method, + # automatically adding common headers and performing generic + # error handling for SAP API responses. + headers = COMMON_HEADERS.copy() + if 'headers' in kwargs: + headers.update(kwargs['headers']) + kwargs['headers'] = headers + + if 'allow_redirects' not in kwargs: + kwargs['allow_redirects'] = True + + res = self.session.request(method, url, **kwargs) + + # Validating against `res.text` can cause long execution time, because fuzzy search result can contain large `res.text`. + # This can be prevented by validating `res.status_code` check before `res.text`. + # Example: 'Two-Factor Authentication' is only in `res.text`, which can lead to long execution. + if res.status_code == 403: + if 'You are not authorized to download this file' in res.text: + raise Exception('You are not authorized to download this file.') + elif 'Account Temporarily Locked Out' in res.text: + raise Exception('Account Temporarily Locked Out. Please reset password to regain access and try again.') + else: + res.raise_for_status() + + if res.status_code == 404: + if 'The file you have requested cannot be found' in res.text: + raise Exception('The file you have requested cannot be found.') + else: + res.raise_for_status() + + res.raise_for_status() + return res + + def get(self, url, **kwargs): + return self.request('GET', url, **kwargs) + + def post(self, url, **kwargs): + return self.request('POST', url, **kwargs) + + def head(self, url, **kwargs): + return self.request('HEAD', url, **kwargs) + + def get_cookies(self): + return self.session.cookies \ No newline at end of file diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 5b74e21..b4c8975 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -1,3 +1,4 @@ +# SAP Launchpad & Software Center URLs URL_LAUNCHPAD = 'https://launchpad.support.sap.com' URL_SOFTWARE_CENTER_SERVICE = 'https://launchpad.support.sap.com/services/odata/svt/swdcuisrv' URL_SOFTWARE_CENTER_VERSION = 'https://launchpad.support.sap.com/applications/softwarecenter/version.json' @@ -6,31 +7,39 @@ 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' -# Maintenance Planner + +# Maintenance Planner URLs 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' -# The following URLs are hardcoded for Gigya Auth. -# TODO: Try to avoid them somehow. +# Gigya Authentication URLs +# These URLs are part of the SAP Universal ID (Gigya) authentication flow. URL_ACCOUNT = 'https://accounts.sap.com' URL_ACCOUNT_CORE_API = 'https://core-api.account.sap.com/uid-core' URL_ACCOUNT_CDC_API = 'https://cdc-api.account.sap.com' URL_ACCOUNT_SSO_IDP = 'https://cdc-api.account.sap.com/saml/v2.0/{k}/idp/sso/continue' - URL_ACCOUNT_SAML_PROXY = 'https://account.sap.com/core/SAMLProxyPage.html' URL_SUPPORT_PORTAL = 'https://hana.ondemand.com/supportportal' +# HTTP Headers & User Agents USER_AGENT_CHROME = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) ' 'AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/72.0.3626.109 Safari/537.36') +# Common headers sent with most requests to mimic a browser. COMMON_HEADERS = {'User-Agent': USER_AGENT_CHROME} + +# Specific headers required for Gigya API requests. GIGYA_HEADERS = { 'User-Agent': USER_AGENT_CHROME, 'Origin': URL_ACCOUNT, 'Referer': URL_ACCOUNT, 'Accept': '*/*', } + +# General Configuration +# The maximum number of times to retry a failed network request. +MAX_RETRY_TIMES = 3 diff --git a/plugins/module_utils/exceptions.py b/plugins/module_utils/exceptions.py new file mode 100644 index 0000000..a50054b --- /dev/null +++ b/plugins/module_utils/exceptions.py @@ -0,0 +1,27 @@ +# Custom exceptions for the sap_launchpad collection. + + +class SapLaunchpadError(Exception): + # Base exception for all application-specific errors. + pass + + +class AuthenticationError(SapLaunchpadError): + # Raised for errors during the authentication process. + pass + + +class AuthorizationError(SapLaunchpadError): + # Raised when a user is not authorized to perform an action. + pass + + +class DownloadError(SapLaunchpadError): + # Raised for errors during the download process, like a checksum mismatch. + pass + + +class FileNotFoundError(SapLaunchpadError): + # Raised when a searched file cannot be found. + pass + diff --git a/plugins/module_utils/sap_api_common.py b/plugins/module_utils/sap_api_common.py deleted file mode 100644 index 164b173..0000000 --- a/plugins/module_utils/sap_api_common.py +++ /dev/null @@ -1,114 +0,0 @@ -import logging -import re -from urllib.parse import urlparse - -import requests -import urllib3 -from requests.adapters import HTTPAdapter - -from .constants import COMMON_HEADERS - - -# By default, the `Authorization` header for Basic Auth will be removed -# if the redirect is to a different host. -# In our case, the DirectDownloadLink with `softwaredownloads.sap.com` domain -# will be redirected to `origin.softwaredownloads.sap.com`, -# so we need to override `rebuild_auth` to perseve the Authorization header -# for sap.com domains. -# This is only required for legacy API. -class SessionAllowBasicAuthRedirects(requests.Session): - def rebuild_auth(self, prepared_request, response): - if 'Authorization' in prepared_request.headers: - request_hostname = urlparse(prepared_request.url).hostname - if not re.match(r'.*sap.com$', request_hostname): - del prepared_request.headers['Authorization'] - - -def _request(url, **kwargs): - global https_session - if 'headers' not in kwargs: - kwargs['headers'] = COMMON_HEADERS - else: - kwargs['headers'].update(COMMON_HEADERS) - - if 'allow_redirects' not in kwargs: - kwargs['allow_redirects'] = True - - method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET' - res = https_session.request(method, url, **kwargs) - - # Validating against `res.text` can cause long execution time, because fuzzy search result can contain large `res.text`. - # This can be prevented by validating `res.status_code` check before `res.text`. - # Example: 'Two-Factor Authentication' is only in `res.text`, which can lead to long execution. - - if res.status_code == 403: - if 'You are not authorized to download this file' in res.text: - raise Exception(f'You are not authorized to download this file.') - elif 'Account Temporarily Locked Out' in res.text: - raise Exception(f'Account Temporarily Locked Out. Please reset password to regain access and try again.') - else: - res.raise_for_status() - - if res.status_code == 404: - if 'The file you have requested cannot be found' in res.text: - raise Exception(f'The file you have requested cannot be found.') - else: - res.raise_for_status() - - res.raise_for_status() - - return res - - -def debug_https_session(): - return https_session - - -def debug_https(): - from http.client import HTTPConnection - HTTPConnection.debuglevel = 1 - logging.basicConfig(level=logging.DEBUG) - logging.debug('Debug is enabled') - - -def debug_get_session_cookie(session): - return '; '.join(f'{k}={v}' for k, v in session.cookies.items()) - - -def flag_is_login(): - return 'IDP_SESSION_MARKER_accounts' in https_session.cookies.keys() - - -def flag_is_gigya(): - return 'gmid' in https_session.cookies.keys() - - -def is_updated_urllib3(): - # `method_whitelist` argument for Retry is deprecated since 1.26.0, - # and will be removed in v2.0.0. - # Typically, the default version on RedHat 8.2 is 1.24.2, - # so we need to check the version of urllib3 to see if it's updated. - urllib3_version = urllib3.__version__.split('.') - if len(urllib3_version) == 2: - urllib3_version.append('0') - major, minor, patch = urllib3_version - major, minor, patch = int(major), int(minor), int(patch) - if (major, minor, patch) >= (1, 26, 0): - return True - return False - - -https_session = SessionAllowBasicAuthRedirects() -retries = urllib3.Retry(connect=3, - read=3, - status=3, - status_forcelist=[413, 429, 500, 502, 503, 504, 509], - backoff_factor=1) -allowed_methods = frozenset( - ['HEAD', 'GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'TRACE']) -if is_updated_urllib3(): - retries.allowed_methods = allowed_methods -else: - retries.method_whitelist = allowed_methods -https_session.mount('https://', HTTPAdapter(max_retries=retries)) -https_session.mount('http://', HTTPAdapter(max_retries=retries)) diff --git a/plugins/module_utils/sap_id_sso.py b/plugins/module_utils/sap_id_sso.py deleted file mode 100644 index 1078d56..0000000 --- a/plugins/module_utils/sap_id_sso.py +++ /dev/null @@ -1,252 +0,0 @@ -import json -import logging -import re -from urllib.parse import parse_qs, quote_plus, urljoin - -from bs4 import BeautifulSoup -from requests.models import HTTPError - -from . import constants as C -from .sap_api_common import _request, https_session - -logger = logging.getLogger(__name__) - -GIGYA_SDK_BUILD_NUMBER = None - - -def _get_sso_endpoint_meta(url, **kwargs): - res = _request(url, **kwargs) - soup = BeautifulSoup(res.content, features='lxml') - - # SSO returns 200 OK even when the crendential is wrong, so we need to - # detect the HTTP body for auth error message. This is only necessary - # for non-universal SID. For universal SID, the client will raise 401 - # during Gygia auth. - error_message = soup.find('div', {'id': 'globalMessages'}) - if error_message and 'we could not authenticate you' in error_message.text: - res.status_code = 401 - res.reason = 'Unauthorized' - res.raise_for_status() - - form = soup.find('form') - if not form: - raise ValueError( - f'Unable to find form: {res.url}\nContent:\n{res.text}') - inputs = form.find_all('input') - - endpoint = urljoin(res.url, form['action']) - metadata = { - i.get('name'): i.get('value') - for i in inputs if i.get('type') != 'submit' and i.get('name') - } - - return (endpoint, metadata) - - -def sap_sso_login(username, password): - https_session.cookies.clear() - - # Ensure usage of SAP User ID even when SAP Universal ID is used, - # login with email address of SAP Universal ID will otherwise - # incorrectly default to the last used SAP User ID - if not re.match(r'^[sS]\d+$', username): - raise ValueError('Please login with SAP User ID (like `S1234567890`)') - - endpoint = C.URL_LAUNCHPAD - meta = {} - - while ('SAMLResponse' not in meta and 'login_hint' not in meta): - endpoint, meta = _get_sso_endpoint_meta(endpoint, data=meta) - if 'j_username' in meta: - meta['j_username'] = username - meta['j_password'] = password - if 'changePassword' in endpoint: - raise ValueError('SAP ID Service has requested `Change Your Password`, possibly the password is too old. Please reset manually and try again.') - - if 'authn' in endpoint: - support_endpoint, support_meta = _get_sso_endpoint_meta(endpoint, - data=meta) - _request(support_endpoint, data=support_meta) - - if 'gigya' in endpoint: - params = _get_gigya_login_params(endpoint, data=meta) - _gigya_websdk_bootstrap(params) - auth_code = _get_gigya_auth_code(username, password) - login_token = _get_gigya_login_token(params, auth_code) - - uid = _get_uid(params, login_token) - id_token = _get_id_token(params, login_token) - uid_details = _get_uid_details(uid, id_token) - if _is_uid_linked_multiple_sids(uid_details): - _select_account(uid, username, id_token) - - idp_endpoint = C.URL_ACCOUNT_SSO_IDP.format(k=params['apiKey']) - context = { - 'loginToken': login_token, - 'samlContext': params['samlContext'] - } - endpoint, meta = _get_sso_endpoint_meta(idp_endpoint, - params=context, - allow_redirects=False) - - while (endpoint != C.URL_LAUNCHPAD + '/'): - endpoint, meta = _get_sso_endpoint_meta(endpoint, - data=meta, - headers=C.GIGYA_HEADERS, - allow_redirects=False) - - _request(endpoint, data=meta, headers=C.GIGYA_HEADERS) - - -def _get_gigya_login_params(url, data): - gigya_idp_res = _request(url, data=data) - - extracted_url_params = re.sub(r'^.*?\?', '', gigya_idp_res.url) - params = {k: v[0] for k, v in parse_qs(extracted_url_params).items()} - return params - - -def _gigya_websdk_bootstrap(params): - page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'], - params.update({ - 'pageURL': page_url, - 'sdk': 'js_latest', - 'sdkBuild': '12426', - 'format': 'json', - }) - - _request(C.URL_ACCOUNT_CDC_API + '/accounts.webSdkBootstrap', - params=params, - headers=C.GIGYA_HEADERS) - - -def _get_gigya_auth_code(username, password): - - auth = {'login': username, 'password': password} - - headers = C.GIGYA_HEADERS.copy() - headers['Content-Type'] = 'application/json;charset=utf-8' - - res = _request( - C.URL_ACCOUNT_CORE_API + '/authenticate', - params={'reqId': C.URL_SUPPORT_PORTAL}, - data=json.dumps(auth), - headers=headers, - ) - j = res.json() - - auth_code = j.get('cookieValue') - return auth_code - - -def _get_gigya_login_token(saml_params, auth_code): - query_params = { - 'sessionExpiration': '0', - 'authCode': auth_code, - } - j = _cdc_api_request('socialize.notifyLogin', saml_params, query_params) - token = j.get('login_token') - logger.debug(f'loging_token: {token}') - return token - - -def _get_id_token(saml_params, login_token): - query_params = { - 'expiration': '180', - 'login_token': login_token, - } - - j = _cdc_api_request('accounts.getJWT', saml_params, query_params) - token = j.get('id_token') - logger.debug(f'id_token: {token}') - return token - - -def _get_uid(saml_params, login_token): - query_params = { - 'include': 'profile,data', - 'login_token': login_token, - } - j = _cdc_api_request('accounts.getAccountInfo', saml_params, query_params) - uid = j.get('UID') - logger.debug(f'UID: {uid}') - return uid - - -def _get_uid_details(uid, id_token): - url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}' - headers = C.GIGYA_HEADERS.copy() - headers['Authorization'] = f'Bearer {id_token}' - - j = _request(url, headers=headers).json() - return j - - -def _is_uid_linked_multiple_sids(uid_details): - accounts = uid_details['accounts'] - linked = [] - for _, v in accounts.items(): - linked.extend(v['linkedAccounts']) - - logger.debug(f'linked account: \n {linked}') - return len(linked) > 1 - - -def _select_account(uid, sid, id_token): - url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}/selectedAccount' - data = {'idsName': sid, 'automatic': 'false'} - - headers = C.GIGYA_HEADERS.copy() - headers['Authorization'] = f'Bearer {id_token}' - return https_session.put(url, headers=headers, json=data) - - -def _get_sdk_build_number(api_key): - global GIGYA_SDK_BUILD_NUMBER - if GIGYA_SDK_BUILD_NUMBER is not None: - return GIGYA_SDK_BUILD_NUMBER - - res = _request('https://cdns.gigya.com/js/gigya.js', - params={'apiKey': api_key}) - js = res.text - match = re.search(r'gigya.build\s*=\s*{[\s\S]+"number"\s*:\s*(\d+),', js) - if not match: - raise HTTPError("unable to find gigya sdk build number", res.response) - - build_number = match.group(1) - logger.debug(f'gigya sdk build number: {build_number}') - GIGYA_SDK_BUILD_NUMBER = build_number - return build_number - - -def _cdc_api_request(endpoint, saml_params, query_params): - url = '/'.join((C.URL_ACCOUNT_CDC_API, endpoint)) - - query = '&'.join([f'{k}={v}' for k, v in saml_params.items()]) - page_url = quote_plus('?'.join((C.URL_ACCOUNT_SAML_PROXY, query))) - - api_key = saml_params['apiKey'] - sdk_build = _get_sdk_build_number(api_key) - - params = { - 'sdk': 'js_latest', - 'APIKey': api_key, - 'authMode': 'cookie', - 'pageURL': page_url, - 'sdkBuild': sdk_build, - 'format': 'json' - } - - if query_params: - params.update(query_params) - - res = _request(url, params=params, headers=C.GIGYA_HEADERS) - j = json.loads(res.text) - logging.debug(f'cdc API response: \n {res.text}') - - error_code = j['errorCode'] - if error_code != 0: - http_error_msg = '{} Error: {} for url: {}'.format( - j['statusCode'], j['errorMessage'], res.url) - raise HTTPError(http_error_msg, response=res) - return j diff --git a/plugins/module_utils/sap_launchpad_software_center_download_runner.py b/plugins/module_utils/sap_launchpad_software_center_download_runner.py deleted file mode 100644 index 8469773..0000000 --- a/plugins/module_utils/sap_launchpad_software_center_download_runner.py +++ /dev/null @@ -1,327 +0,0 @@ -import hashlib -import json -import logging -import os -import time - -from requests.auth import HTTPBasicAuth -from requests.exceptions import HTTPError - -from . import constants as C -from .sap_api_common import _request, https_session -from .sap_id_sso import _get_sso_endpoint_meta -from .sap_launchpad_software_center_download_search_fuzzy import * - -logger = logging.getLogger(__name__) - -_HAS_DOWNLOAD_AUTHORIZATION = None -MAX_RETRY_TIMES = 3 - - -def search_software_filename(name, deduplicate, search_alternatives): - """ - Execute search for SAP Software or its alternative when search_alternatives is true. - - Args: - name: The filename name to check (e.g. 'SAPCAR_1115-70006178.EXE'). - deduplicate: Select deduplication logic from 'first', 'last' - search_alternatives: Boolean for enabling fuzzy search. - - Returns: - download_link: Download link of matched SAP Software. - filename: File name of matched SAP Software. - alternative_found: True if alternative search was successful. - """ - - alternative_found = False - software_search = _search_software(name) - software_filtered = [r for r in software_search if r['Title'] == name or r['Description'] == name] - - files_count=len(software_filtered) - if files_count == 0: - # Run fuzzy search if search_alternatives was selected - if search_alternatives: - software_fuzzy_found = search_software_fuzzy(name) - software_fuzzy_filtered, suggested_filename = filter_fuzzy_search(software_fuzzy_found, name) - if len(software_fuzzy_filtered) == 0: - raise ValueError(f'File {name} is not available to download and has no alternatives') - - software_fuzzy_alternatives = software_fuzzy_filtered[0].get('Title') - - # Search has to be filtered again, because API call can get - # duplicates like 70SWPM10SP43_2-20009701.sar for SWPM10SP43_2-20009701.SAR - software_search_alternatives = _search_software(software_fuzzy_alternatives) - software_search_alternatives_filtered = [ - file for file in software_search_alternatives - if file.get('Title', '').startswith(suggested_filename) - ] - alternatives_count=len(software_search_alternatives_filtered) - if alternatives_count == 0: - raise ValueError(f'File {name} is not available to download and has no alternatives') - elif alternatives_count > 1 and deduplicate == '': - names = [s['Title'] for s in software_search_alternatives_filtered] - raise ValueError('More than one results were found: %s. ' - 'please use the correct full filename' % names) - elif alternatives_count > 1 and deduplicate == 'first': - software_found = software_search_alternatives_filtered[0] - alternative_found = True - elif alternatives_count > 1 and deduplicate == 'last': - software_found = software_search_alternatives_filtered[alternatives_count-1] - alternative_found = True - else: - software_found = software_search_alternatives_filtered[0] - alternative_found = True - else: - raise ValueError(f'File {name} is not available to download. Enable "search_alternatives" to search for alternatives.') - - elif files_count > 1 and deduplicate == '': - names = [s['Title'] for s in software_filtered] - raise ValueError('More than one results were found: %s. ' - 'please use the correct full filename' % names) - elif files_count > 1 and deduplicate == 'first': - software_found = software_filtered[0] - elif files_count > 1 and deduplicate == 'last': - software_found = software_filtered[files_count-1] - else: - software_found = software_filtered[0] - - download_link = software_found['DownloadDirectLink'] - filename = _get_valid_filename(software_found) - - return (download_link, filename, alternative_found) - - -def download_software(download_link, filename, output_dir, retry=0): - """Download software from DownloadDirectLink and save it as filename - """ - # User might not have authorization to download software. - if not _has_download_authorization(): - raise UserWarning( - 'You do not have proper authorization to download software, ' - 'please check: ' - 'https://launchpad.support.sap.com/#/user/authorizations') - - endpoint = download_link - meta = {} - - # if SESSIONID is in the cookie list and it's valid, - # then we can download file without SAML authentication - # during tokengen (/tokengen/?file=fileid) - if not https_session.cookies.get('SESSIONID', - domain='.softwaredownloads.sap.com'): - try: - while ('SAMLResponse' not in meta): - endpoint, meta = _get_sso_endpoint_meta(endpoint, data=meta) - # 403 Error could be raised during the final SAML submit for tokengen. - # If the request succeeds, it will be redirected to the real download URL. - res = _request(endpoint, data=meta, stream=True) - except HTTPError as e: - # clear cookies including SESSIONID because we are not authed - https_session.cookies.clear('.softwaredownloads.sap.com') - if e.response.status_code != 403 or retry >= MAX_RETRY_TIMES: - raise - logger.warning('[403] Retry %d time(s) for %s', - retry+1, e.request.url) - time.sleep(60*(retry+1)) - return download_software(download_link, filename, output_dir, retry+1) - except ConnectionError as e: - # builtin Connection Error is not handled by requests. - if retry >= MAX_RETRY_TIMES: - raise - logger.warning('[ConnectionError] Retry %d time(s): %s', retry+1, e) - time.sleep(60*(retry+1)) - return download_software(download_link, filename, output_dir, retry+1) - - res.close() - endpoint = res.url - - logger.debug("real download url: %s", endpoint) - filepath = os.path.join(output_dir, filename) - _download_file(endpoint, filepath) - - -def is_download_link_available(url, retry=0): - """Verify the DownloadDirectLink - """ - # User might not have authorization to download software. - if not _has_download_authorization(): - raise UserWarning( - 'You do not have proper authorization to download software, ' - 'please check: ' - 'https://launchpad.support.sap.com/#/user/authorizations') - - try: - # if SESSIONID is in the cookie list and it's valid, - # then we can download file without SAML authentication - if not https_session.cookies.get('SESSIONID', - domain='.softwaredownloads.sap.com'): - meta = {} - while ('SAMLResponse' not in meta): - url, meta = _get_sso_endpoint_meta(url, data=meta) - res = _request(url, stream=True, data=meta) - else: - res = _request(url, stream=True) - except HTTPError as e: - # clear cookies including SESSIONID because we are not authed - https_session.cookies.clear('.softwaredownloads.sap.com') - if e.response.status_code == 404: - return False - if e.response.status_code != 403 or retry >= MAX_RETRY_TIMES: - raise - logger.warning('[403] Retry %d time(s) for %s', - retry+1, e.request.url) - time.sleep(60*(retry+1)) - return is_download_link_available(url, retry+1) - except ConnectionError as e: - # builtin Connection Error is not handled by requests. - if retry >= MAX_RETRY_TIMES: - raise - logger.warning('[ConnectionError] Retry %d time(s): %s', retry+1, e) - time.sleep(60*(retry+1)) - return is_download_link_available(url, retry+1) - finally: - _clear_download_key_cookie() - - # close explicitly is required for stream request. - res.close() - - # test if we have a file download request in the end. - content_header = res.headers.get('Content-Disposition') - available = content_header and 'attachment;' in content_header - return available - - -def download_software_via_legacy_api(username, password, download_link, - filename, output_dir): - filepath = os.path.join(output_dir, filename) - - _download_file(download_link, - filepath, - retry=0, - auth=HTTPBasicAuth(username, password)) - - -def _search_software(keyword): - - url = C.URL_SOFTWARE_CENTER_SERVICE + '/SearchResultSet' - params = { - 'SEARCH_MAX_RESULT': 500, - 'RESULT_PER_PAGE': 500, - 'SEARCH_STRING': keyword, - } - query_string = '&'.join([f'{k}={v}' for k, v in params.items()]) - query_url = '?'.join((url, query_string)) - - headers = {'User-Agent': C.USER_AGENT_CHROME, 'Accept': 'application/json'} - results = [] - try: - res = _request(query_url, headers=headers, allow_redirects=False) - j = json.loads(res.text) - results = j['d']['results'] - except json.JSONDecodeError: - # When use has no authority to search some specified files, - # it will return non-json response, which is actually expected. - # So just return an empty list. - logger.warning('Non-JSON response returned for software searching') - logger.debug(res.text) - - return results - - -def _download_file(url, filepath, retry=0, **kwargs): - # Read response as stream, in case the file is huge. - kwargs.update({'stream': True}) - try: - res = _request(url, **kwargs) - with open(filepath, 'wb') as f: - # 1MiB Chunk - for chunk in res.iter_content(chunk_size=1024 * 1024): - f.write(chunk) - except ConnectionError: - # builtin Connection Error is not handled by requests. - if retry >= MAX_RETRY_TIMES: - # Remove partial file if exists. - if os.path.exists(filepath): - os.remove(filepath) - raise - time.sleep(60*(retry+1)) - return _download_file(url, filepath, retry+1, **kwargs) - - res.close() - _clear_download_key_cookie() - - checksum = res.headers.get('ETag', '').replace('"', '') - logger.debug("checksum: %s; url: %s", checksum, res.request.url) - if (not checksum) or _is_checksum_matched(filepath, checksum): - return - - # If checksum validation fails, the file on disk is considered invalid. - # Remove it to ensure the next attempt (retry or external) starts fresh. - logger.warning("checksum mismatch: %s: %s", filepath, checksum) - if os.path.exists(filepath): - os.remove(filepath) - - if retry >= MAX_RETRY_TIMES: - raise RuntimeError(f'failed to download {url}: checksum mismatch after {MAX_RETRY_TIMES} retries') - return _download_file(url, filepath, retry+1, **kwargs) - - -def _has_download_authorization(): - global _HAS_DOWNLOAD_AUTHORIZATION - if _HAS_DOWNLOAD_AUTHORIZATION is None: - user_attributes = _request(C.URL_ACCOUNT_ATTRIBUTES).json() - sid = user_attributes['uid'] - - url = C.URL_SERVICE_USER_ADMIN + f"/UserSet('{sid}')/UserExistingAuthorizationsSet" - j = _request(url, headers={'Accept': 'application/json'}).json() - authorization_objs = [r['ObjectId'] for r in j['d']['results']] - authorization_descs = [r['ObjectDesc'] for r in j['d']['results']] - _HAS_DOWNLOAD_AUTHORIZATION = "Software Download" in authorization_descs or (True for x in ["SWDOWNLOAD", "G_SOFTDOWN"] if x in authorization_objs) - return _HAS_DOWNLOAD_AUTHORIZATION - - -def _clear_download_key_cookie(): - # Software download server generates a cookie for every single file. - # If we don't clear it after download, the cookie header will become - # too long and the server will reject the request. - for c in https_session.cookies: - if c.domain == '.softwaredownloads.sap.com' and c.name != 'SESSIONID': - https_session.cookies.clear(name=c.name, domain=c.domain, path='/') - - -def _is_checksum_matched(f, etag): - # SAP Software Download Server is using MD5 and sha256 for ETag Header: - # MD5 ETag: "e054445edd671fc1d01cc4f3dce6c84a:1634267161.876855" - # SHA256 ETag: "14ce8940ff262ceb67823573b3dec3aee2b3cbb452c73601569d5876d02af8b0" - checksum = etag.split(":")[0] - hash = hashlib.md5() - if len(checksum) == 64: - hash = hashlib.sha256() - with open(f, "rb") as f: - for chunk in iter(lambda: f.read(4096 * hash.block_size), b""): - hash.update(chunk) - return hash.hexdigest() == checksum - - -def _get_valid_filename(software_found): - """ - Ensure that CD Media have correct filenames from description. - Example: S4CORE105_INST_EXPORT_1.zip downloads as 19118000000000004323 - - Args: - software_found: List[0] with dictionary of file. - - Returns: - Valid filename for CD Media files, where applicable. - """ - - # Check if Title contains filename and extension - if re.match(r'^\d+$', software_found['Title']): - # Check if Description attribute exists and that it does not contain empty spaces - if software_found['Description'] and ' ' not in software_found['Description']: - return software_found['Description'] - else: - return software_found['Title'] - else: - # Default to Title if Description does not help - return software_found['Title'] 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 deleted file mode 100644 index 9fba4cf..0000000 --- a/plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py +++ /dev/null @@ -1,323 +0,0 @@ -import csv -import logging -import os -import re - -import requests - -from . import constants as C -from .sap_api_common import _request - - -def search_software_fuzzy(query, max=None, csv_filename=None): - """ - Execute fuzzy search using Unique Software ID instead of name. - ID is unique to Product and Platform combination. - Example of shared ID 80002616: - - SYBCTRL_1440-80002616.SAR - - SYBCTRL_1436-80002616.SAR - - Args: - query: The filename name to check (e.g. 'SYBCTRL_1440-80002616.SAR'). - - Returns: - The list of dict for the software results. - Empty list is returned if query does not contain ID. - """ - # Format query to split filename. - filename_base = os.path.splitext(query)[0] # Remove extension - - # Ensure that fuzzy search is run only for valid IDs. - # This excludes unique files without ID like: S4CORE105_INST_EXPORT_1.zip - if '-' in filename_base: - filename_id = filename_base.split('-')[-1] # Split id from filename - else: - return [] - - results = _search_software(filename_id) - num = 0 - - fuzzy_results = [] - while True: - for r in results: - r = _remove_useless_keys(r) - fuzzy_results.append(r) - num += len(results) - # quit if no results or results number reach the max - if num == 0 or (max and num >= max): - break - query_string = _get_next_page_query(results[-1]['SearchResultDescr']) - if not query_string: - break - try: - results = _get_software_search_results(query_string) - # Sometimes it responds 50x http error for some keywords, - # but it's not the client's fault. - except requests.exceptions.HTTPError as e: - logging.warning(f'{e.response.status_code} HTTP Error occurred ' - f'during pagination: {e.response.url}') - break - - if csv_filename: - _write_software_results(fuzzy_results, csv_filename) - return - return fuzzy_results - - -def filter_fuzzy_search(fuzzy_results, filename): - """ - Filter fuzzy search output using filename. - - Args: - fuzzy_results: Output of search_software_fuzzy. - filename: The filename name to check - - Returns: - fuzzy_results_sorted: The list of files that match the filter criteria, sorted by 'Title' in descending order. - suggested_filename: Return generated keyword for further reuse after API call. - """ - - # Prepare filtered list for specific SPS - suggested_filename = _prepare_search_filename_specific(filename) - - fuzzy_results_filtered = [ - file for file in fuzzy_results - if file.get('Title', '').startswith(suggested_filename) - ] - - # Repeat filtering without specific SPS - if len(fuzzy_results_filtered) == 0: - suggested_filename = _prepare_search_filename_nonspecific(filename) - - fuzzy_results_filtered = [ - file for file in fuzzy_results - if file.get('Title', '').startswith(suggested_filename) - ] - - # fuzzy_results_sorted = sorted(fuzzy_results_filtered, key=lambda item: item.get('Title', ''), reverse=True) - fuzzy_results_sorted =_sort_fuzzy_results(fuzzy_results_filtered, filename) - - return fuzzy_results_sorted, suggested_filename - - -def _prepare_search_filename_specific(filename): - """ - Prepare suggested search keyword for known products specific to SPS version. - - Args: - filename: The filename name to check - - Returns: - Suggested filename to filter fuzzy search. - """ - - # Format query to split filename. - filename_base = os.path.splitext(filename)[0] # Remove extension - filename_name = filename_base.rsplit('_', 1)[0] # Split software name from version - # Following filenames will be processed using default filename_name split. - # Return SYBCTRL for SYBCTRL_1436-80002616.SAR - # Return SMDA720 for SMDA720_SP11_22-80003641.SAR - - - for swpm_version in ("70SWPM1", "70SWPM2", "SWPM1", "SWPM2"): - if filename_base.startswith(swpm_version): - return swpm_version - - # Return SUM11SP04 for SUM11SP04_2-80006858.SAR - if filename_base.startswith('SUM'): - return filename.split('-')[0].split('_')[0] - - # Return DBATL740O11 for DBATL740O11_48-80002605.SAR - elif filename_base.startswith('DBATL'): - return filename.split('-')[0].split('_')[0] - - # Return IMDB_AFL20_077 for IMDB_AFL20_077_0-80002045.SAR - # Return IMDB_AFL100_102P for IMDB_AFL100_102P_41-10012328.SAR - elif filename_base.startswith('IMDB_AFL'): - return "_".join(filename.split('-')[0].split('_')[:3]) - - # Return IMDB_CLIENT20_021 for IMDB_CLIENT20_021_31-80002082.SAR - elif filename_base.startswith('IMDB_CLIENT'): - return "_".join(filename.split('-')[0].split('_')[:3]) - - # IMDB_LCAPPS for SAP HANA 1.0 - # Return IMDB_LCAPPS_122 for IMDB_LCAPPS_122P_3300-20010426.SAR - elif filename_base.startswith('IMDB_LCAPPS_1'): - filename_parts = filename.split('-')[0].rsplit('_', 2) - return f"{filename_parts[0]}_{filename_parts[1][:3]}" - - # IMDB_LCAPPS for SAP HANA 2.0 - # Return IMDB_LCAPPS_206 for IMDB_LCAPPS_2067P_400-80002183.SAR - elif filename_base.startswith('IMDB_LCAPPS_2'): - filename_parts = filename.split('-')[0].rsplit('_', 2) - return f"{filename_parts[0]}_{filename_parts[1][:3]}" - - # Return IMDB_SERVER20_06 (SPS06) for IMDB_SERVER20_067_4-80002046.SAR - elif filename_base.startswith('IMDB_SERVER'): - filename_parts = filename.split('-')[0].rsplit('_', 2) - return f"{filename_parts[0]}_{filename_parts[1][:2]}" - - # Return SAPEXE_100 for SAPEXE_100-80005374.SAR - elif filename_base.startswith('SAPEXE'): - return filename_base.split('-')[0] - - # Return SAPHANACOCKPIT02 (SPS02) for SAPHANACOCKPIT02_0-70002300.SAR - elif filename_base.startswith('SAPHANACOCKPIT'): - return filename_base.split('-')[0].rsplit('_', 1)[0] - - # Return unchanged filename_name - else: - return filename_name - - -def _prepare_search_filename_nonspecific(filename): - """ - Prepare suggested search keyword for known products nonspecific to SPS version. - - Args: - filename: The filename name to check - - Returns: - Suggested filename to filter fuzzy search. - """ - - # Format query to split filename. - filename_base = os.path.splitext(filename)[0] # Remove extension - filename_name = filename_base.rsplit('_', 1)[0] # Split software name from version - - # Return SUM11 for SUM11SP04_2-80006858.SAR - if filename_base.startswith('SUM'): - if filename_base.startswith('SUMHANA'): - return 'SUMHANA' - elif filename_base[3:5].isdigit(): # Allow only SUM and 2 digits - return filename_base[:5] - - # Return DBATL740O11 for DBATL740O11_48-80002605.SAR - elif filename_base.startswith('DBATL'): - return filename.split('-')[0].split('_')[0] - - # Return IMDB_AFL20 for IMDB_AFL20_077_0-80002045.SAR - # Return IMDB_AFL100 for IMDB_AFL100_102P_41-10012328.SAR - elif filename_base.startswith('IMDB_AFL'): - return "_".join(filename.split('-')[0].split('_')[:2]) - - # Return IMDB_CLIENT for IMDB_CLIENT20_021_31-80002082.SAR - elif filename_base.startswith('IMDB_CLIENT'): - return 'IMDB_CLIENT' - - # Return IMDB_LCAPPS for IMDB_LCAPPS_122P_3300-20010426.SAR - elif filename_base.startswith('IMDB_LCAPPS'): - return "_".join(filename.split('-')[0].split('_')[:2]) - - # Return IMDB_SERVER20 for IMDB_SERVER20_067_4-80002046.SAR - elif filename_base.startswith('IMDB_SERVER'): - return "_".join(filename.split('-')[0].split('_')[:2]) - - # Return SAPHANACOCKPIT for SAPHANACOCKPIT02_0-70002300.SAR - elif filename_base.startswith('SAPHANACOCKPIT'): - return 'SAPHANACOCKPIT' - - # Return SAPHOSTAGENT for SAPHOSTAGENT61_61-80004831.SAR - elif filename_base.startswith('SAPHOSTAGENT'): - return 'SAPHOSTAGENT' - - # Return unchanged filename_name - else: - return filename_name - - -def _sort_fuzzy_results(fuzzy_results_filtered, filename): - """ - Sort results of fuzzy search for known nonstandard versions. - Example: - IMDB_LCAPPS_122P_3500-20010426.SAR, IMDB_LCAPPS_122P_600-70001332.SAR - - Args: - fuzzy_results_filtered: The list of filtered fuzzy results. - filename: The filename name to check. - - Returns: - Ordered list of fuzzy results, based on known nonstandard versions. - """ - - if _get_numeric_search_keyword(filename): - software_fuzzy_sorted = sorted( - fuzzy_results_filtered, - key= lambda item: _get_numeric_search_keyword(item.get('Title', '')), - reverse=True, - ) - else: - software_fuzzy_sorted = sorted( - fuzzy_results_filtered, - key=lambda item: item.get('Title', ''), - reverse=True, - ) - - return software_fuzzy_sorted - - -def _get_numeric_search_keyword(filename): - """ - Extract integer value of version from filename. - - Args: - filename: The filename name to check. - - """ - match = re.search(r'_(\d+)-', filename) - if match: - return int(match.group(1)) - else: - return None - - -def _search_software(keyword, remove_useless_keys=False): - params = { - 'SEARCH_MAX_RESULT': 500, - 'RESULT_PER_PAGE': 500, - 'SEARCH_STRING': keyword, - } - query_string = '&'.join([f'{k}={v}' for k, v in params.items()]) - results = _get_software_search_results(query_string) - if remove_useless_keys: - results = [_remove_useless_keys(r) for r in results] - return results - - -def _get_software_search_results(query_string): - url = C.URL_SOFTWARE_CENTER_SERVICE + '/SearchResultSet' - query_url = '?'.join((url, query_string)) - - headers = {'User-Agent': C.USER_AGENT_CHROME, 'Accept': 'application/json'} - res = _request(query_url, headers=headers, allow_redirects=False).json() - - results = res['d']['results'] - return results - - -def _remove_useless_keys(result): - keys = [ - 'Title', 'Description', 'Infotype', 'Fastkey', 'DownloadDirectLink', - 'ContentInfoLink' - ] - return {k: result[k] for k in keys} - - -def _get_next_page_query(desc): - if '|' not in desc: - return None - - _, url = desc.split('|') - return url.strip() - - -def _write_software_results(results, output): - with open(output, 'w', newline='') as f: - fieldsnames = [ - 'Title', 'Description', 'Infotype', 'Fastkey', - 'DownloadDirectLink', 'ContentInfoLink' - ] - writer = csv.DictWriter(f, fieldnames=fieldsnames) - writer.writeheader() - for r in results: - writer.writerow(r) diff --git a/plugins/module_utils/software_center/__init__.py b/plugins/module_utils/software_center/__init__.py new file mode 100644 index 0000000..63d44d9 --- /dev/null +++ b/plugins/module_utils/software_center/__init__.py @@ -0,0 +1 @@ +# This file makes the `software_center` directory a Python package. \ No newline at end of file diff --git a/plugins/module_utils/software_center/download.py b/plugins/module_utils/software_center/download.py new file mode 100644 index 0000000..d326b50 --- /dev/null +++ b/plugins/module_utils/software_center/download.py @@ -0,0 +1,189 @@ +import glob +import hashlib +import os +import time + +from requests.exceptions import ConnectionError, HTTPError + +from .. import auth +from .. import constants as C +from .. import exceptions +from . import search + +_HAS_DOWNLOAD_AUTHORIZATION = None + +def validate_local_file_checksum(client, local_filepath, query=None, download_link=None, deduplicate=None): + # Validates a local file against the remote checksum from the server. + # Returns a dictionary with the validation status and a descriptive message. + try: + if query: + # As we are validating an existing file, search_alternatives is forced to False. + file_details = search.find_file(client, query, deduplicate, search_alternatives=False) + download_link = file_details['download_link'] + + download_link_final = _resolve_download_link(client, download_link) + + try: + # A HEAD request is not always supported; a streaming GET is more reliable. + res = client.get(download_link_final, stream=True) + headers = res.headers + res.close() # We only need the headers, so close the connection. + finally: + _clear_download_key_cookie(client) + + remote_etag = headers.get('ETag') + + if not remote_etag: + return {'validated': None, 'message': f"Checksum validation skipped: ETag header not found for URL '{download_link_final}'. Headers received: {headers}"} + + if _is_checksum_matched(local_filepath, remote_etag): + return {'validated': True, 'message': 'Local file checksum is valid.'} + else: + return {'validated': False, 'message': 'Local file checksum is invalid.'} + + except exceptions.SapLaunchpadError as e: + return {'validated': None, 'message': f'Checksum validation skipped: {e}'} + + +def _check_similar_files(dest, filename): + # Checks for similar files in the download path based on the given filename. + if os.path.splitext(filename)[1]: + filename_base = os.path.splitext(filename)[0] + filename_pattern = os.path.join(dest, "**", filename_base + ".*") + else: + filename_pattern = os.path.join(dest, "**", filename + ".*") + + filename_similar = glob.glob(filename_pattern, recursive=True) + + if filename_similar: + filename_similar_names = [os.path.basename(f) for f in filename_similar] + return True, filename_similar_names + else: + return False, [] + + +def _check_download_authorization(client): + # Verifies that the authenticated user has the "Software Download" authorization. + # Caches the result to avoid repeated API calls. + global _HAS_DOWNLOAD_AUTHORIZATION + if _HAS_DOWNLOAD_AUTHORIZATION is None: + try: + user_attributes = client.get(C.URL_ACCOUNT_ATTRIBUTES).json() + sid = user_attributes['uid'] + + url = C.URL_SERVICE_USER_ADMIN + f"/UserSet('{sid}')/UserExistingAuthorizationsSet" + auth_response = client.get(url, headers={'Accept': 'application/json'}).json() + + authorization_objs = [r['ObjectId'] for r in auth_response['d']['results']] + authorization_descs = [r['ObjectDesc'] for r in auth_response['d']['results']] + + _HAS_DOWNLOAD_AUTHORIZATION = "Software Download" in authorization_descs or any( + x in authorization_objs for x in ["SWDOWNLOAD", "G_SOFTDOWN"] + ) + except Exception as e: + _HAS_DOWNLOAD_AUTHORIZATION = False + + if not _HAS_DOWNLOAD_AUTHORIZATION: + raise exceptions.AuthorizationError( + 'User does not have proper authorization to download software. ' + 'Please check authorizations at: https://launchpad.support.sap.com/#/user/authorizations' + ) + + +def _is_download_link_available(client, url, retry=0): + # Verifies if a download link is active and returns the final, resolved URL. + # Returns None if the link is not available. + # IMPORTANT: This function leaves download cookies in the session on success. + try: + final_url = _resolve_download_link(client, url) + # A HEAD request is not always supported; a streaming GET is more reliable. + res = client.get(final_url, stream=True) + res.close() # We only need the headers, so close the connection. + content_header = res.headers.get('Content-Disposition') + if content_header and 'attachment;' in content_header: + return final_url + return None + except exceptions.DownloadError: + return None + + +def _resolve_download_link(client, url, retry=0): + # Resolves a tokengen URL to the final, direct download URL. + # This encapsulates the SAML token exchange logic and includes retries. + _check_download_authorization(client) + endpoint = url + + # If a session for the download domain doesn't exist, we need to go through + # the SAML SSO flow to get a download token. + if not client.session.cookies.get('SESSIONID', domain='.softwaredownloads.sap.com'): + try: + meta = {} + while 'SAMLResponse' not in meta: + endpoint, meta = auth._get_sso_endpoint_meta(client, endpoint, data=meta) + + # This POST will result in a redirect to the actual file URL. + res = client.post(endpoint, data=meta, stream=True) + res.close() # We don't need the content, just the redirect URL and cookies. + return res.url + except (HTTPError, ConnectionError) as e: + client.session.cookies.clear(domain='.softwaredownloads.sap.com') + # Retry on 403 (Forbidden) as it can be a temporary token issue. + if (isinstance(e, HTTPError) and e.response.status_code != 403) or retry >= C.MAX_RETRY_TIMES: + raise exceptions.DownloadError(f"Could not resolve download URL after {C.MAX_RETRY_TIMES} retries: {e}") + + time.sleep(60 * (retry + 1)) + return _resolve_download_link(client, url, retry + 1) + + # If a session already exists, the provided URL can be used directly. + return endpoint + + +def _stream_file_to_disk(client, url, filepath, retry=0, **kwargs): + # Streams a large file to disk and verifies its checksum. + kwargs.update({'stream': True}) + try: + res = client.get(url, **kwargs) + with open(filepath, 'wb') as f: + for chunk in res.iter_content(chunk_size=1024 * 1024): # 1MiB chunks + f.write(chunk) + except ConnectionError as e: + if os.path.exists(filepath): + os.remove(filepath) + if retry >= C.MAX_RETRY_TIMES: + raise exceptions.DownloadError(f"Connection failed after {C.MAX_RETRY_TIMES} retries: {e}") + time.sleep(60 * (retry + 1)) + return _stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) + + res.close() + _clear_download_key_cookie(client) + + checksum = res.headers.get('ETag', '').replace('"', '') + if not checksum or _is_checksum_matched(filepath, checksum): + return + + if os.path.exists(filepath): + os.remove(filepath) + + if retry >= C.MAX_RETRY_TIMES: + raise exceptions.DownloadError(f'Failed to download {url}: checksum mismatch after {C.MAX_RETRY_TIMES} retries') + return _stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) + + +def _clear_download_key_cookie(client): + # Clears download-specific cookies to prevent the cookie header from becoming too large. + for c in list(client.session.cookies): + if c.domain == '.softwaredownloads.sap.com' and c.name != 'SESSIONID': + client.session.cookies.clear(name=c.name, domain=c.domain, path='/') + + +def _is_checksum_matched(filepath, etag): + # Verifies a file's checksum against an ETag, supporting MD5 and SHA256. + # ETag values are often enclosed in double quotes, which must be removed. + clean_etag = etag.strip('"') + checksum = clean_etag.split(":")[0] + hash_algo = hashlib.md5() if len(checksum) == 32 else hashlib.sha256() + + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(4096 * hash_algo.block_size), b""): + hash_algo.update(chunk) + return hash_algo.hexdigest() == checksum diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py new file mode 100644 index 0000000..e479673 --- /dev/null +++ b/plugins/module_utils/software_center/main.py @@ -0,0 +1,153 @@ +import os + +from .. import auth +from .. import exceptions +from ..client import ApiClient +from . import download +from . import search + + +def run_software_download(params): + # The main "runner" function for the software_center_download module. + # It orchestrates the entire process and returns a result dictionary. + + result = { + 'changed': False, + 'skipped': False, + 'failed': False, + 'msg': '', + 'filename': '', + 'alternative': False, + 'warnings': [] + } + + username = params.get('suser_id') + password = params.get('suser_password') + dest = params['dest'] + download_link = params.get('download_link') + download_filename = params.get('download_filename') + dry_run = params.get('dry_run') + deduplicate = params.get('deduplicate') + search_alternatives = params.get('search_alternatives') + validate_checksum = params.get('validate_checksum') + + if params['search_query']: + query = params['search_query'] + elif params['softwarecenter_search_query']: + query = params['softwarecenter_search_query'] + result['warnings'].append("The 'softwarecenter_search_query' is deprecated. Use 'search_query' instead.") + else: + query = None + + if not (query or (download_link and download_filename)): + result['failed'] = True + result['msg'] = "Either 'search_query' or both 'download_link' and 'download_filename' must be provided." + return result + + filename = query if query else download_filename + result['filename'] = filename + + filepath = os.path.join(dest, filename) + + # --- Pre-authentication checks --- + # If checksum validation is not requested, we can perform a quick check + # for the file's existence and skip authentication if it's already there. + if not validate_checksum: + if os.path.exists(filepath): + result['skipped'] = True + result['msg'] = f"File already exists: {filename}" + return result + + filename_similar_exists, filename_similar_names = download._check_similar_files(dest, filename) + if filename_similar_exists: + result['skipped'] = True + result['msg'] = f"Similar file(s) already exist: {', '.join(filename_similar_names)}" + return result + + client = ApiClient() + try: + auth.login(client, username, password) + + validation_result = None + # --- Post-authentication checks --- + # If checksum validation is requested, we perform the check here, + # now that we have an authenticated session. + if validate_checksum and os.path.exists(filepath): + validation_result = download.validate_local_file_checksum(client, filepath, query=query, download_link=download_link, deduplicate=deduplicate) + if validation_result['validated'] is True: + result['skipped'] = True + result['msg'] = f"File already exists and checksum is valid: {filename}" + return result + elif validation_result['validated'] is False: + # The existing file is invalid, remove it to allow for re-download. + # The final message will explain why the re-download occurred. + os.remove(filepath) + else: # Validation could not be performed + result['skipped'] = True + result['msg'] = f"File already exists: {filename}. {validation_result['message']}" + return result + + alternative_found = False + if query: + file_details = search.find_file(client, query, deduplicate, search_alternatives) + download_link = file_details['download_link'] + download_filename = file_details['filename'] + alternative_found = file_details['alternative_found'] + + result['filename'] = download_filename + result['alternative'] = alternative_found + + alt_filepath = os.path.join(dest, download_filename) + if filename != download_filename and os.path.exists(alt_filepath): + if validate_checksum: + # We already have the download_link for the alternative file, so we can validate it directly. + validation_result = download.validate_local_file_checksum(client, alt_filepath, download_link=download_link) + if validation_result['validated'] is True: + result['skipped'] = True + result['msg'] = f"Alternative file {download_filename} already exists and checksum is valid." + return result + elif validation_result['validated'] is False: + # The existing alternative file is invalid, remove it to allow for re-download. + os.remove(alt_filepath) + else: # Validation could not be performed + result['skipped'] = True + result['msg'] = f"Alternative file {download_filename} already exists. {validation_result['message']}" + return result + else: + result['skipped'] = True + result['msg'] = f"File with correct/alternative name already exists: {download_filename}" + return result + + final_url = download._is_download_link_available(client, download_link) + if final_url: + if dry_run: + msg = f"SAP Software is available to download: {download_filename}" + if alternative_found: + msg = f"Alternative SAP Software is available to download: {download_filename} - original file {query} is not available" + result['msg'] = msg + else: + # The link is already resolved, just download it. + filepath = os.path.join(dest, download_filename) + download._stream_file_to_disk(client, final_url, filepath) + result['changed'] = True + + if validation_result and validation_result.get('validated') is False: + result['msg'] = f"Successfully re-downloaded {download_filename} due to an invalid checksum." + elif alternative_found: + result['msg'] = f"Successfully downloaded alternative SAP software: {download_filename} - original file {query} is not available to download" + else: + result['msg'] = f"Successfully downloaded SAP software: {download_filename}" + else: + result['failed'] = True + result['msg'] = f"Download link for {download_filename} is not available." + + except exceptions.SapLaunchpadError as e: + result['failed'] = True + result['msg'] = str(e) + except Exception as e: + result['failed'] = True + result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" + finally: + download._clear_download_key_cookie(client) + + return result \ No newline at end of file diff --git a/plugins/module_utils/software_center/search.py b/plugins/module_utils/software_center/search.py new file mode 100644 index 0000000..f560512 --- /dev/null +++ b/plugins/module_utils/software_center/search.py @@ -0,0 +1,301 @@ +import csv +import json +import os +import re + +from .. import constants as C +from ..exceptions import FileNotFoundError + + +def find_file(client, name, deduplicate, search_alternatives): + # Main search function to find a software file. + # It performs a direct search and, if requested, a fuzzy search for alternatives. + # Returns a dictionary with file details. + alternative_found = False + + # First, attempt a direct search for the exact filename. + software_search = _search_software(client, name) + software_filtered = [r for r in software_search if r['Title'] == name or r['Description'] == name] + + files_count = len(software_filtered) + if files_count == 0: + # If no exact match is found, and alternatives are requested, perform a fuzzy search. + if not search_alternatives: + raise FileNotFoundError(f'File "{name}" is not available. To find a replacement, enable "search_alternatives".') + + software_fuzzy_found = _search_software_fuzzy(client, name) + software_fuzzy_filtered, suggested_filename = _filter_fuzzy_search(software_fuzzy_found, name) + if len(software_fuzzy_filtered) == 0: + raise FileNotFoundError(f'File "{name}" is not available and no alternatives could be found.') + + software_fuzzy_alternatives = software_fuzzy_filtered[0].get('Title') + + # The fuzzy search can return duplicates (e.g., .sar and .SAR). + # We must perform another direct search on the best alternative and filter it. + # duplicates like 70SWPM10SP43_2-20009701.sar for SWPM10SP43_2-20009701.SAR + software_search_alternatives = _search_software(client, software_fuzzy_alternatives) + software_search_alternatives_filtered = [ + file for file in software_search_alternatives + if file.get('Title', '').startswith(suggested_filename) + ] + + alternatives_count = len(software_search_alternatives_filtered) + if alternatives_count == 0: + raise FileNotFoundError(f'File "{name}" is not available and no alternatives could be found.') + elif alternatives_count > 1 and deduplicate == '': + names = [s['Title'] for s in software_search_alternatives_filtered] + raise FileNotFoundError(f'More than one alternative was found: {", ".join(names)}. Please use a more specific filename.') + elif alternatives_count > 1 and deduplicate == 'first': + software_found = software_search_alternatives_filtered[0] + alternative_found = True + elif alternatives_count > 1 and deduplicate == 'last': + software_found = software_search_alternatives_filtered[alternatives_count - 1] + alternative_found = True + else: + # Default to the first alternative found. + software_found = software_search_alternatives_filtered[0] + alternative_found = True + + elif files_count > 1 and deduplicate == '': + # Handle cases where the direct search returns multiple exact matches. + names = [s['Title'] for s in software_filtered] + raise FileNotFoundError(f'More than one result was found: {", ".join(names)}. Please use the correct full filename.') + elif files_count > 1 and deduplicate == 'first': + software_found = software_filtered[0] + elif files_count > 1 and deduplicate == 'last': + software_found = software_filtered[files_count - 1] + else: + # The ideal case: exactly one result was found. + software_found = software_filtered[0] + + return { + 'download_link': software_found['DownloadDirectLink'], + 'filename': _get_valid_filename(software_found), + 'alternative_found': alternative_found + } + + +def _search_software(client, keyword): + # Performs a direct search for a software file by keyword. + url = C.URL_SOFTWARE_CENTER_SERVICE + '/SearchResultSet' + params = { + 'SEARCH_MAX_RESULT': 500, + 'RESULT_PER_PAGE': 500, + 'SEARCH_STRING': keyword, + } + headers = {'User-Agent': C.USER_AGENT_CHROME, 'Accept': 'application/json'} + results = [] + try: + res = client.get(url, params=params, headers=headers, allow_redirects=False) + json_data = res.json() + results = json_data.get('d', {}).get('results', []) + except json.JSONDecodeError: + # This can happen if the user lacks authorization for a specific file. + # The API returns non-JSON, so we return an empty list. + pass + + return results + + +def _search_software_fuzzy(client, query): + # Executes a fuzzy search using the unique software ID from the filename. + filename_base = os.path.splitext(query)[0] + + # This excludes unique files without ID like: S4CORE105_INST_EXPORT_1.zip + if '-' not in filename_base: + return [] + + filename_id = filename_base.split('-')[-1] + results = _search_software(client, filename_id) + num = 0 + + fuzzy_results = [] + while True: + for r in results: + r = _remove_useless_keys(r) + fuzzy_results.append(r) + num += len(results) + + if not results: + break + + query_string = _get_next_page_query(results[-1]['SearchResultDescr']) + if not query_string: + break + + url = C.URL_SOFTWARE_CENTER_SERVICE + '/SearchResultSet' + query_url = '?'.join((url, query_string)) + headers = {'User-Agent': C.USER_AGENT_CHROME, 'Accept': 'application/json'} + results = client.get(query_url, headers=headers, allow_redirects=False).json().get('d', {}).get('results', []) + + return fuzzy_results + + +def _filter_fuzzy_search(fuzzy_results, filename): + # Filters fuzzy search output using the original filename. + suggested_filename = _prepare_search_filename_specific(filename) + fuzzy_results_filtered = [ + file for file in fuzzy_results + if file.get('Title', '').startswith(suggested_filename) + ] + + if len(fuzzy_results_filtered) == 0: + suggested_filename = _prepare_search_filename_nonspecific(filename) + fuzzy_results_filtered = [ + file for file in fuzzy_results + if file.get('Title', '').startswith(suggested_filename) + ] + + fuzzy_results_sorted = _sort_fuzzy_results(fuzzy_results_filtered, filename) + return fuzzy_results_sorted, suggested_filename + + +def _prepare_search_filename_specific(filename): + # Prepares a suggested search keyword for known products specific to SPS version. + filename_base = os.path.splitext(filename)[0] + filename_name = filename_base.rsplit('_', 1)[0] + + for swpm_version in ("70SWPM1", "70SWPM2", "SWPM1", "SWPM2"): + if filename_base.startswith(swpm_version): + return swpm_version + + # Example: SUM11SP04_2-80006858.SAR returns SUM11SP04 + if filename_base.startswith('SUM'): + return filename.split('-')[0].split('_')[0] + + # Example: DBATL740O11_48-80002605.SAR returns DBATL740O11 + elif filename_base.startswith('DBATL'): + return filename.split('-')[0].split('_')[0] + + # Example: IMDB_AFL20_077_0-80002045.SAR returns IMDB_AFL20_077 + # Example: IMDB_AFL100_102P_41-10012328.SAR returns MDB_AFL100_102P + elif filename_base.startswith('IMDB_AFL'): + return "_".join(filename.split('-')[0].split('_')[:3]) + + # Example: IMDB_CLIENT20_021_31-80002082.SAR returns IMDB_CLIENT20_021 + elif filename_base.startswith('IMDB_CLIENT'): + return "_".join(filename.split('-')[0].split('_')[:3]) + + # Example: IMDB_LCAPPS_122P_3300-20010426.SAR returns IMDB_LCAPPS_122 + elif filename_base.startswith('IMDB_LCAPPS_1'): + filename_parts = filename.split('-')[0].rsplit('_', 2) + return f"{filename_parts[0]}_{filename_parts[1][:3]}" + + # Example: IMDB_LCAPPS_2067P_400-80002183.SAR returns IMDB_LCAPPS_206 + elif filename_base.startswith('IMDB_LCAPPS_2'): + filename_parts = filename.split('-')[0].rsplit('_', 2) + return f"{filename_parts[0]}_{filename_parts[1][:3]}" + + # Example: IMDB_SERVER20_067_4-80002046.SAR returns IMDB_SERVER20_06 (SPS06) + elif filename_base.startswith('IMDB_SERVER'): + filename_parts = filename.split('-')[0].rsplit('_', 2) + return f"{filename_parts[0]}_{filename_parts[1][:2]}" + + # Example: SAPEXE_100-80005374.SAR returns SAPEXE_100 + elif filename_base.startswith('SAPEXE'): + return filename_base.split('-')[0] + + # Example: SAPHANACOCKPIT02_0-70002300.SAR returns SAPHANACOCKPIT02 (SPS02) + elif filename_base.startswith('SAPHANACOCKPIT'): + return filename_base.split('-')[0].rsplit('_', 1)[0] + else: + return filename + + +def _prepare_search_filename_nonspecific(filename): + # Prepares a suggested search keyword for known products non-specific to SPS version. + filename_base = os.path.splitext(filename)[0] + filename_name = filename_base.rsplit('_', 1)[0] + + # Example: SUM11SP04_2-80006858.SAR returns SUM11 + if filename_base.startswith('SUM'): + if filename_base.startswith('SUMHANA'): + return 'SUMHANA' + elif filename_base[3:5].isdigit(): + return filename_base[:5] + + # Example: DBATL740O11_48-80002605.SAR returns DBATL740O11 + elif filename_base.startswith('DBATL'): + return filename.split('-')[0].split('_')[0] + + # Example: IMDB_AFL20_077_0-80002045.SAR returns IMDB_AFL20 + # Example: IMDB_AFL100_102P_41-10012328.SAR returns IMDB_AFL100 + elif filename_base.startswith('IMDB_AFL'): + return "_".join(filename.split('-')[0].split('_')[:2]) + + # Example: IMDB_CLIENT20_021_31-80002082.SAR returns IMDB_CLIENT + elif filename_base.startswith('IMDB_CLIENT'): + return 'IMDB_CLIENT' + + # Example: IMDB_LCAPPS_122P_3300-20010426.SAR returns IMDB_LCAPPS + elif filename_base.startswith('IMDB_LCAPPS'): + return "_".join(filename.split('-')[0].split('_')[:2]) + + # Example: IMDB_SERVER20_067_4-80002046.SAR returns IMDB_SERVER20 + elif filename_base.startswith('IMDB_SERVER'): + return "_".join(filename.split('-')[0].split('_')[:2]) + + # Example: SAPHANACOCKPIT02_0-70002300.SAR returns SAPHANACOCKPIT + elif filename_base.startswith('SAPHANACOCKPIT'): + return 'SAPHANACOCKPIT' + + # Example: SAPHOSTAGENT61_61-80004831.SAR returns SAPHOSTAGENT + elif filename_base.startswith('SAPHOSTAGENT'): + return 'SAPHOSTAGENT' + + return filename + + +def _sort_fuzzy_results(fuzzy_results_filtered, filename): + # Sorts results of fuzzy search for known nonstandard versions. + if _get_numeric_search_keyword(filename): + software_fuzzy_sorted = sorted( + fuzzy_results_filtered, + key=lambda item: _get_numeric_search_keyword(item.get('Title', '')), + reverse=True, + ) + else: + software_fuzzy_sorted = sorted( + fuzzy_results_filtered, + key=lambda item: item.get('Title', ''), + reverse=True, + ) + return software_fuzzy_sorted + + +def _get_numeric_search_keyword(filename): + # Extracts integer value of version from filename. + match = re.search(r'_(\d+)-', filename) + if match: + return int(match.group(1)) + else: + return None + + +def _remove_useless_keys(result): + # Filters a result dictionary to keep only essential keys. + keys = [ + 'Title', 'Description', 'Infotype', 'Fastkey', 'DownloadDirectLink', + 'ContentInfoLink' + ] + return {k: result[k] for k in keys} + + +def _get_next_page_query(desc): + # Extracts the next page query URL for paginated search results. + if '|' not in desc: + return None + _, url = desc.split('|') + return url.strip() + + +def _get_valid_filename(software_found): + # Ensures that CD Media have correct filenames from description. + # Example: S4CORE105_INST_EXPORT_1.zip downloads as 19118000000000004323 + if re.match(r'^\d+$', software_found['Title']): + if software_found['Description'] and ' ' not in software_found['Description']: + return software_found['Description'] + else: + return software_found['Title'] + else: + return software_found['Title'] diff --git a/plugins/modules/README.md b/plugins/modules/README.md deleted file mode 100644 index 7401ca3..0000000 --- a/plugins/modules/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Ansible Modules documentation - -Each Ansible Module has documentation underneath `/docs`. \ No newline at end of file diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index 9ec4e0f..80257d5 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -1,11 +1,7 @@ -# -*- coding: utf-8 -*- - -# SAP software download module +#!/usr/bin/python from __future__ import absolute_import, division, print_function -__metaclass__ = type - DOCUMENTATION = r''' --- module: software_center_download @@ -68,6 +64,11 @@ - Check availability of SAP Software without downloading. required: false type: bool + validate_checksum: + description: + - If a file with the same name already exists at the destination, validate its checksum against the remote file. If the checksum is invalid, the local file will be removed and re-downloaded. + required: false + type: bool author: - SAP LinuxLab @@ -75,11 +76,10 @@ EXAMPLES = r''' - name: Download using search query - community.sap_launchpad.sap_launchpad_software_center_download: + community.sap_launchpad.software_center_download: suser_id: 'SXXXXXXXX' suser_password: 'password' - search_query: - - 'SAPCAR_1324-80000936.EXE' + search_query: 'SAPCAR_1324-80000936.EXE' dest: "/tmp/" - name: Download using direct link and filename community.sap_launchpad.software_center_download: @@ -106,44 +106,10 @@ ''' -######################### - -import requests -import glob from ansible.module_utils.basic import AnsibleModule -# Import runner -from ..module_utils.sap_launchpad_software_center_download_runner import * -from ..module_utils.sap_id_sso import sap_sso_login - -def _check_similar_files(dest, filename): - """ - Checks for similar files in the download path based on the given filename. - - Args: - dest (str): The path where files are downloaded. - filename (str): The filename to check for similar files. - - Returns: - bool: True if similar files exist, False otherwise. - filename_similar_names: A list of similar filenames if they exist, empty list otherwise. - """ - - # Check if filename has has extension and remove it for search - if os.path.splitext(filename)[1]: - filename_base = os.path.splitext(filename)[0] - filename_pattern = os.path.join(dest, "**", filename_base + ".*") - else: - filename_pattern = os.path.join(dest, "**", filename + ".*") - - # Find all similar files in dest and sub-folders. - filename_similar = glob.glob(filename_pattern, recursive=True) - - if filename_similar: - filename_similar_names = [os.path.basename(f) for f in filename_similar] - return True, filename_similar_names - else: - return False, [] +# Import the main runner function from the module_utils +from ..module_utils.software_center import main as software_center_runner def run_module(): @@ -159,7 +125,8 @@ def run_module(): dest=dict(type='str', required=True), dry_run=dict(type='bool', required=False, default=False), deduplicate=dict(type='str', required=False, default=''), - search_alternatives=dict(type='bool', required=False, default=False) + search_alternatives=dict(type='bool', required=False, default=False), + validate_checksum=dict(type='bool', required=False, default=False) ) # Instantiate module @@ -168,145 +135,20 @@ def run_module(): supports_check_mode=True ) - # Define variables based on module inputs - username = module.params.get('suser_id') - password = module.params.get('suser_password') - - if module.params['search_query'] != '': - query = module.params['search_query'] - elif module.params['softwarecenter_search_query'] != '': - query = module.params['softwarecenter_search_query'] - module.warn("The 'softwarecenter_search_query' is deprecated and will be removed in a future version. Use 'search_query' instead.") - else: - query = None - - dest = module.params['dest'] - download_link= module.params.get('download_link') - download_filename= module.params.get('download_filename') - dry_run = module.params.get('dry_run') - deduplicate = module.params.get('deduplicate') - search_alternatives = module.params.get('search_alternatives') - - # Define result dictionary objects to be passed back to Ansible - result = dict( - changed=False, - msg='', - filename=download_filename, - alternative=False, - skipped=False, - failed=False - ) - - # Check mode if module.check_mode: - module.exit_json(**result) - - - # Main - try: - - # Ensure that required parameters are provided - if not (query or (download_link and download_filename)): - module.fail_json( - msg="Either 'search_query' or both 'download_link' and 'download_filename' must be provided." - ) - - # Search for existing files using exact filename - filename = query if query else download_filename - filename_exact = os.path.join(dest, filename) - result['filename'] = filename - - if os.path.exists(filename_exact): - result['skipped'] = True - result['msg'] = f"File already exists: {filename}" - module.exit_json(**result) - else: - # Exact file not found, search for similar files with pattern - filename_similar_exists, filename_similar_names = _check_similar_files(dest, filename) - if filename_similar_exists: - result['skipped'] = True - result['msg'] = f"Similar file(s) already exist: {', '.join(filename_similar_names)}" - module.exit_json(**result) - - # Initiate login with given credentials - sap_sso_login(username, password) - - # Execute search_software_filename first to get download link and filename - alternative_found = False # True if search_alternatives was successful - if query: - download_link, download_filename, alternative_found = search_software_filename(query,deduplicate,search_alternatives) + module.exit_json(changed=False) - # Search for existing files again with alternative filename - if search_alternatives and alternative_found: - result['filename'] = download_filename - result['alternative'] = True + result = software_center_runner.run_software_download(module.params) - filename_alternative_exact = os.path.join(dest, download_filename) - if os.path.exists(filename_alternative_exact): - result['skipped'] = True - result['msg'] = f"Alternative file already exists: {download_filename} - original file {query} is not available to download" - module.exit_json(**result) - else: - # Exact file not found, search for similar files with pattern - filename_similar_exists, filename_similar_names = _check_similar_files(dest, download_filename) - if filename_similar_exists: - result['skipped'] = True - result['msg'] = f"Similar alternative file(s) already exist: {', '.join(filename_similar_names)}" - module.exit_json(**result) - - # Triggers for CD Media, where number was changed to name using _get_valid_filename - elif filename != download_filename and not alternative_found: - result['filename'] = download_filename + # The runner function can also return warnings for the module to display. + for warning in result.pop('warnings', []): + module.warn(warning) - if os.path.exists(os.path.join(dest, download_filename)): - result['skipped'] = True - result['msg'] = f"File already exists with correct name: {download_filename}" - module.exit_json(**result) - else: - # Exact file not found, search for similar files with pattern - filename_similar_exists, filename_similar_names = _check_similar_files(dest, download_filename) - if filename_similar_exists: - result['skipped'] = True - result['msg'] = f"Similar file(s) already exist for correct name {download_filename}: {', '.join(filename_similar_names)}" - module.exit_json(**result) - - - # Ensure that download_link is provided when query was not provided - if not download_link: - module.fail_json(msg="Missing parameters 'query' or 'download_link'.") - - # Exit module before download when dry_run is true - if dry_run: - available = is_download_link_available(download_link) - if available and query and not alternative_found: - result['msg'] = f"SAP Software is available to download: {download_filename}" - module.exit_json(**result) - elif available and query and alternative_found: - result['msg'] = f"Alternative SAP Software is available to download: {download_filename} - original file {query} is not available to download" - module.exit_json(**result) - else: - module.fail_json(msg="Download link {} is not available".format(download_link)) - - download_software(download_link, download_filename, dest) - - # Update final results json - result['changed'] = True - if query and alternative_found: - result['msg'] = f"Successfully downloaded alternative SAP software: {download_filename} - original file {query} is not available to download" - else: - result['msg'] = f"Successfully downloaded SAP software: {download_filename}" - - except requests.exceptions.HTTPError as e: - # module.fail_json(msg='SAP SSO authentication failed' + str(e), **result) - result['msg'] = "SAP SSO authentication failed - " + str(e) - result['failed'] = True - except Exception as e: - # module.fail_json(msg='An exception has occurred' + str(e), **result) - result['msg'] = "An exception has occurred - " + str(e) - result['failed'] = True - - # Return to Ansible - module.exit_json(**result) + # The runner function indicates failure via a key in the result. + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) def main(): From 7034b47426a620611a2ec75acf2ac5fac2f09e94 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 9 Sep 2025 11:36:56 +0200 Subject: [PATCH 2/9] doc: update module documentation after refactor --- README.md | 8 +- docs/module_license_keys.md | 253 ++++++++++++++++++ docs/module_maintenance_planner_files.md | 30 ++- ..._maintenance_planner_stack_xml_download.md | 28 +- docs/module_software_center_download.md | 68 ++++- docs/module_systems_info.md | 139 ++++++++++ 6 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 docs/module_license_keys.md create mode 100644 docs/module_systems_info.md diff --git a/README.md b/README.md index 11d1a40..52724e5 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ See [Installing collections](https://docs.ansible.com/ansible/latest/collections ### Ansible Modules | Name | Summary | | :-- | :-- | -| [sap_launchpad.software_center_download](./docs/module_software_center_download.md) | Search and download SAP Software file | -| [sap_launchpad.maintenance_planner_files](./docs/module_maintenance_planner_files.md) | Get list of files from Maintenance Planner | -| [sap_launchpad.maintenance_planner_stack_xml_download](./docs/module_maintenance_planner_stack_xml_download.md) | Get stack file from Maintenance Planner | +| [sap_launchpad.software_center_download](./docs/module_software_center_download.md) | Downloads software from the SAP Software Center | +| [sap_launchpad.maintenance_planner_files](./docs/module_maintenance_planner_files.md) | Retrieves a list of files from an SAP Maintenance Planner transaction| +| [sap_launchpad.maintenance_planner_stack_xml_download](./docs/module_maintenance_planner_stack_xml_download.md) | Downloads the stack.xml file from an SAP Maintenance Planner transaction | +| [sap_launchpad.license_keys](./docs/module_license_keys.md) | Creates systems and license keys | +| [sap_launchpad.systems_info](./docs/module_systems_info.md) | Retrieves information about SAP systems | ### Ansible Roles | Name | Summary | diff --git a/docs/module_license_keys.md b/docs/module_license_keys.md new file mode 100644 index 0000000..c18dbd4 --- /dev/null +++ b/docs/module_license_keys.md @@ -0,0 +1,253 @@ +# license_keys Ansible Module + +## Description +The Ansible Module `license_keys` creates and updates systems and their license keys using the SAP Launchpad API. +- It is closely modeled after the interactions in the portal at `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, hardware key, and other potential attributes. +- The system and license data is then validated and submitted to the API, and the license key file content is returned. +- This module attempts to be as idempotent as possible. If a system with the same SID is found under the installation, it will be updated instead of creating a new one. + +## Dependencies +This module requires the following Python modules to be installed on the target node: + +- wheel +- urllib3 +- requests +- beautifulsoup4 +- lxml + +Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) + +## Execution + +### Execution Flow +The module follows a sophisticated logic flow to determine whether to create, update, or remove systems and licenses. + +1. **Authentication**: + * The module authenticates with the provided S-User credentials to establish a valid session. + * It validates that the user has access to the specified `installation_nr`. + +2. **System Identification (Idempotency Check)**: + * **If `system.nr` is provided:** The module targets the specified system for updates. + * **If `system.nr` is NOT provided:** + * The module searches for an existing system using the `sysid` from `system.data` and the `installation_nr`. + * **If one system is found:** It targets that system for updates and issues a warning. + * **If multiple systems are found:** The module fails with an error, asking the user to provide a specific `system.nr` to select one. + * **If no system is found:** The module proceeds to create a new system. + +3. **Action: Create New System**: + * Validates the provided `product`, `version`, and `system.data`. + * Validates the provided `licenses`. + * Submits the request to the API to create the new system with its initial licenses. + * The new `system_nr` is returned. + +4. **Action: Update Existing System**: + * **Validation:** It first checks if the `product` and `version` provided in the playbook match the details of the existing system on the portal. If they do not match, the module fails, as changing these properties is not supported. + * It retrieves the list of licenses that already exist on the system. + * **If `delete_other_licenses: false` (default):** + * It compares the `licenses` from the playbook with the existing licenses. + * Only new or changed licenses are sent to the API for creation/update. + * If all specified licenses already exist in the desired state, no changes are made. + * **If `delete_other_licenses: true`:** + * It ensures that only the licenses specified in the playbook exist on the system. + * Any license on the system that is *not* in the playbook's `licenses` list will be deleted. + * If the `licenses` list is empty, all licenses will be removed from the system. + +5. **License File Download**: + * If licenses were successfully created or updated, their content is returned in the `license_file` key. + * If `download_path` is specified, the license file is also saved to that directory. + +### Example +```yaml +- name: Create a new system and generate license keys + community.sap_launchpad.license_keys: + suser_id: 'SXXXXXXXX' + suser_password: 'password' + installation_nr: '12345678' + system: + # 'nr' is omitted to create a new system + product: "SAP S/4HANA" + version: "SAP S/4HANA 2022" + data: + sysid: "S4H" + sysname: "s4hana-new-dev" + systype: "Application Server (ABAP)" + sysdb: "SAP HANA" + sysos: "Linux on x86_64 64bit" + sys_depl: "Private - On Premise" + licenses: + - type: "SAP S/4HANA" + data: + hwkey: "A1234567890" + expdate: "99991231" + download_path: "/tmp/licenses" + register: result + +- name: Update an existing system and remove other licenses + community.sap_launchpad.license_keys: + suser_id: 'SXXXXXXXX' + suser_password: 'password' + installation_nr: '12345678' + system: + nr: '0000123456' # Specify the system number to update + product: "SAP S/4HANA" + version: "SAP S/4HANA 2022" + data: + sysid: "S4H" + sysname: "s4hana-new-dev" + systype: "Application Server (ABAP)" + sysdb: "SAP HANA" + sysos: "Linux on x86_64 64bit" + sys_depl: "Private - On Premise" + licenses: + - type: "SAP S/4HANA" + data: + hwkey: "A1234567890" + expdate: "99991231" + delete_other_licenses: true + register: result + +- name: Display the license file content + ansible.builtin.debug: + var: result.license_file +``` + +### Output format +#### license_file +- _Type:_ `string`
+ +The license file content containing the digital signatures of the specified licenses. This is returned when licenses are successfully generated or updated. +**Sample:** +```text +----- 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 +``` + +## Further Information +### Installation of prerequisites +**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
+ +Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +```yaml +--- +- name: Example play to install prerequisites for sap_launchpad + hosts: all + tasks: + - name: Install Python and Python package manager pip + ansible.builtin.package: + name: + - python311 + - python311-pip + state: present + + - name: Pre-Steps - Install Python modules to Python venv + ansible.builtin.pip: + name: + - wheel + - urllib3 + - requests + - beautifulsoup4 + - lxml + virtualenv: "/tmp/python_venv" + virtualenv_command: "python3.11 -m venv" +``` + +Prerequisite preparation using Python 3.11 system default
+```yaml +--- +- name: Example play to install prerequisites for sap_launchpad + hosts: all + tasks: + - name: Install Python and Python package manager pip + ansible.builtin.package: + name: + - python311 + - python311-pip + state: present + + - name: Install Python module packages + ansible.builtin.package: + name: + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present +``` +**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. + +## License +Apache 2.0 + +## Maintainers +Maintainers are shown within [/docs/contributors](./CONTRIBUTORS.md). + +## Module Variables +### suser_id +- _Required:_ `true`
+- _Type:_ `string`
+ +The SAP S-User ID with authorization to manage systems and licenses. + +### suser_password +- _Required:_ `true`
+- _Type:_ `string`
+ +The password for the SAP S-User specified in `suser_id`. + +### installation_nr +- _Required:_ `true`
+- _Type:_ `string`
+ +The SAP installation number under which the system is registered. + +### system +- _Required:_ `true`
+- _Type:_ `dictionary`
+ +A dictionary containing the details of the system to create or update. +- **nr** (_string_): The 10-digit number of an existing system to update. If this is omitted, the module will attempt to create a new system. +- **product** (_string_): The product description as found in the SAP portal (e.g., `SAP S/4HANA`). +- **version** (_string_): The description of the product version (e.g., `SAP S/4HANA 2022`). +- **data** (_dictionary_): A dictionary of system attributes (e.g., `sysid`, `sysos`). + +### licenses +- _Required:_ `true`
+- _Type:_ `list` of `dictionaries`
+ +A list of licenses to manage for the system. +- **type** (_string_): The license type description as found in the SAP portal (e.g., `Maintenance Entitlement`). +- **data** (_dictionary_): A dictionary of license attributes. The required attributes (e.g., `hwkey`, `expdate`) vary by license type. + +### delete_other_licenses +- _Required:_ `false`
+- _Type:_ `boolean`
+- _Default:_ `false`
+ +If set to `true`, any licenses found on the system that are not specified in the `licenses` list will be removed. + +### download_path +- _Required:_ `false`
+- _Type:_ `path`
+ +If specified, the generated license key file will be downloaded to this directory. diff --git a/docs/module_maintenance_planner_files.md b/docs/module_maintenance_planner_files.md index b1340af..6e1393e 100644 --- a/docs/module_maintenance_planner_files.md +++ b/docs/module_maintenance_planner_files.md @@ -1,7 +1,9 @@ # maintenance_planner_files Ansible Module ## Description -The Ansible Module `maintenance_planner_files` is used to obtain list of SAP Software files belonging to Maintenance Plan transaction. +The Ansible Module `maintenance_planner_files` connects to the SAP Maintenance Planner to retrieve a list of all downloadable files associated with a specific transaction. +- It returns a list containing direct download links and filenames for each file. +- This is useful for automating the download of a complete stack file set defined in a Maintenance Planner transaction. ## Dependencies This module requires the following Python modules to be installed on the target node (the machine where SAP software will be downloaded): @@ -16,6 +18,27 @@ Installation instructions are available at [Installation of prerequisites](#inst ## Execution +### Execution Flow +The module follows a clear logic flow to retrieve the file list from a Maintenance Planner transaction. + +1. **Authentication**: + * The module first authenticates with the provided S-User credentials to establish a general session with the SAP Launchpad. + * It then performs a second authentication step against the `userapps.support.sap.com` service, which is required to access the Maintenance Planner API. + +2. **Transaction Lookup**: + * The module fetches a list of all Maintenance Planner transactions available to the user. + * It searches this list for a transaction that matches the provided `transaction_name` (checking both the name and the display ID). If no match is found, the module fails. + +3. **File List Retrieval**: + * Using the ID of the found transaction, the module makes an API call to retrieve the stack XML file that defines all the downloadable files for that transaction. + * It parses this XML to extract a list of direct download links and their corresponding filenames. + +4. **URL Validation (Optional)**: + * If `validate_url` is set to `true`, the module will perform a `HEAD` request for each download link to verify that it is active and accessible. If any link is invalid, the module will fail. + +5. **Return Data**: + * The module returns the final list of files as the `download_basket`, with each item containing a `DirectLink` and a `Filename`. + ### Example Obtain list of SAP Software files ```yaml @@ -138,3 +161,8 @@ The password for the SAP S-User specified in `suser_id`. - _Type:_ `string`
The name or display ID of a transaction from the SAP Maintenance Planner. + +### validate_url +- _Type:_ `boolean`
+ +Validate if the download links are available and not expired. diff --git a/docs/module_maintenance_planner_stack_xml_download.md b/docs/module_maintenance_planner_stack_xml_download.md index c920652..f670150 100644 --- a/docs/module_maintenance_planner_stack_xml_download.md +++ b/docs/module_maintenance_planner_stack_xml_download.md @@ -1,7 +1,9 @@ # maintenance_planner_stack_xml_download Ansible Module ## Description -The Ansible Module `maintenance_planner_stack_xml_download` is used to obtain Stack file belonging to Maintenance Plan transaction. +The Ansible Module `maintenance_planner_stack_xml_download` connects to the SAP Maintenance Planner to download the `stack.xml` file associated with a specific transaction. +- The `stack.xml` file contains the plan for a system update or installation and is used by tools like Software Update Manager (SUM). +- The file is saved to the specified destination directory. ## Dependencies This module requires the following Python modules to be installed on the target node (the machine where SAP software will be downloaded): @@ -16,6 +18,28 @@ Installation instructions are available at [Installation of prerequisites](#inst ## Execution +### Execution Flow +The module follows a clear logic flow to download the stack XML file from a Maintenance Planner transaction. + +1. **Authentication**: + * The module first authenticates with the provided S-User credentials to establish a general session with the SAP Launchpad. + * It then performs a second authentication step against the `userapps.support.sap.com` service, which is required to access the Maintenance Planner API. + +2. **Transaction Lookup**: + * The module fetches a list of all Maintenance Planner transactions available to the user. + * It searches this list for a transaction that matches the provided `transaction_name` (checking both the name and the display ID). If no match is found, the module fails. + +3. **Stack XML Retrieval**: + * Using the ID of the found transaction, the module makes an API call to download the raw content of the `stack.xml` file. + +4. **File Creation**: + * The module validates that the provided `dest` path is an existing directory. + * It determines the filename from the response headers or creates a default name based on the transaction name. + * It writes the retrieved XML content to the destination file. + +5. **Return Data**: + * The module returns a success message indicating the full path where the `stack.xml` file was saved. + ### Example Obtain Stack file ```yaml @@ -134,4 +158,4 @@ The name or display ID of a transaction from the SAP Maintenance Planner. - _Required:_ `true`
- _Type:_ `string`
-The directory where downloaded SAP software files will be stored. +The path to an existing destination directory where the stack.xml file will be saved. diff --git a/docs/module_software_center_download.md b/docs/module_software_center_download.md index 84254a1..a46a1bf 100644 --- a/docs/module_software_center_download.md +++ b/docs/module_software_center_download.md @@ -1,7 +1,11 @@ # software_center_download Ansible Module ## Description -The Ansible Module `software_center_download` is used to download SAP Software file from SAP. +The Ansible Module `software_center_download` automates downloading files from the SAP Software Center. +- It can find a file using a search query or download it directly using a specific download link and filename. +- If a file is not found via search, it can look for alternative versions. +- It supports checksum validation to ensure file integrity and avoid re-downloading valid files. +- The module can also perform a dry run to check for file availability without downloading. ## Dependencies This module requires the following Python modules to be installed on the target node (the machine where SAP software will be downloaded): @@ -16,10 +20,47 @@ Installation instructions are available at [Installation of prerequisites](#inst ## Execution +### Execution Flow +The module follows a sophisticated logic flow to determine whether to download, skip, or fail. Here is a simplified breakdown of the decision-making process: + +1. **Parameter Validation**: + * The module first ensures that either a `search_query` or both `download_link` and `download_filename` are provided. If not, it fails. + +2. **Pre-flight File Check** (if `validate_checksum: false`): + * Checks if a file with the exact name already exists at the destination. + * **If yes:** The module skips the download and exits. + * Checks for files with a similar base name (e.g., `FILE.*` for `FILE.SAR`). + * **If yes:** The module skips the download and exits. + +3. **Authentication**: + * The module authenticates with the provided S-User credentials to establish a valid session. + +4. **Pre-flight Checksum Validation** (only if `validate_checksum: true` and a local file already exists): + * The module attempts to find the corresponding remote file on the SAP portal to get its checksum. + * If `search_alternatives: true`, it will look for newer versions if the original is not found. + * It compares the local file's checksum with the remote file's checksum (ETag). + * **If an alternative file was found:** The local file is considered outdated and is removed to allow the new version to be downloaded. + * **If checksums do not match:** The local file is invalid and is removed to allow a fresh download. + * **If checksums match (and no alternative was found):** The module skips the download. + * **If checksum cannot be validated (e.g., remote file not found):** The module skips the download with a warning. + +5. **File Search**: + * **If using `search_query`:** + * The module searches for the file. If `search_alternatives: true`, it will look for newer versions if the original is not found. + * If an alternative is found and it already exists locally, its checksum is validated as described in the pre-flight check. + * **If using `download_link`:** + * The module proceeds directly to the download step. + +6. **Download & Post-Download Validation**: + * The module verifies that the final download link is active. + * **If `dry_run: true`:** The module exits with a success message indicating the file is available, without downloading. + * **If `dry_run: false`:** The module streams the file to the destination directory. + * **After every download**, the module automatically validates the downloaded file's checksum against the one provided by the server. If they don't match, it will delete the corrupt file and retry the download. + ### Example Download SAP Software file ```yaml -- name: Download SAP Software file +- name: Download SAP Software file using search_query community.sap_launchpad.software_center_download: suser_id: "Enter SAP S-User ID" suser_password: "Enter SAP S-User Password" @@ -27,16 +68,15 @@ Download SAP Software file dest: "Enter download path (e.g. /software)" ``` -Download SAP Software file, but search for alternatives if not found +Download SAP Software file using download_link and download_filename ```yaml -- name: Download SAP Software file with alternative +- name: Download SAP Software file using download_link and download_filename community.sap_launchpad.software_center_download: suser_id: "Enter SAP S-User ID" suser_password: "Enter SAP S-User Password" - search_query: "Enter SAP Software file name" + download_link: 'https://softwaredownloads.sap.com/file/0010000000048502015' + download_filename: 'IW_FNDGC100.SAR' dest: "Enter download path (e.g. /software)" - search_alternatives: true - deduplicate: "last" ``` Download list of SAP Software files, but search for alternatives if not found @@ -87,7 +127,7 @@ Download SAP Software file using Python Virtual Environment `/tmp/venv` #### msg - _Type:_ `string`
-The status of execution. +A message indicating the status of the download operation. ## Further Information ### Installation of prerequisites @@ -188,10 +228,10 @@ The directory where downloaded SAP software files will be stored. ### deduplicate - _Type:_ `string`
-Specifies how to handle duplicate file results.
+Specifies how to handle multiple search results for the same filename.
If multiple files with the same name are found, this setting determines which one to download.
-- `first`: Download the first file found
-- `last`: Download the last file found.
+- `first`: Download the first file found (oldest).
+- `last`: Download the last file found (newest).
### search_alternatives - _Type:_ `boolean`
@@ -202,3 +242,9 @@ Enables searching for alternative files if the requested file is not found.
- _Type:_ `boolean Check availability of SAP Software without downloading.
+ +### validate_checksum +- _Type:_ `boolean + +If a file with the same name already exists at the destination, validate its checksum against the remote file.
+If the checksum is invalid, the local file will be removed and re-downloaded.
diff --git a/docs/module_systems_info.md b/docs/module_systems_info.md new file mode 100644 index 0000000..56be39a --- /dev/null +++ b/docs/module_systems_info.md @@ -0,0 +1,139 @@ +# systems_info Ansible Module + +## Description +The Ansible Module `systems_info` queries the SAP Launchpad to retrieve a list of registered systems based on a filter string. +- It allows for fetching details about systems associated with the authenticated S-User. +- The OData filter expression allows for precise queries, for example, by installation number, system ID, or product description. + +## Dependencies +This module requires the following Python modules to be installed on the target node: + +- wheel +- urllib3 +- requests +- beautifulsoup4 +- lxml + +Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) + +## Execution + +### Execution Flow +The module follows a straightforward logic flow to retrieve system information. + +1. **Authentication**: + * The module authenticates with the provided S-User credentials to establish a valid session with the SAP Launchpad. + +2. **System Query**: + * The module makes a GET request to the SAP Systems OData API. + * It passes the user-provided `filter` string directly to the API to query for specific systems. + +3. **Return Data**: + * The module returns the list of systems that match the filter criteria in the `systems` key. + * Each system in the list is a dictionary containing its details. + +### Example +```yaml +- name: Get all systems for a specific installation number + community.sap_launchpad.systems_info: + suser_id: 'SXXXXXXXX' + suser_password: 'password' + filter: "Insnr eq '1234567890'" + register: result + +- name: Display system details + ansible.builtin.debug: + var: result.systems + +- name: Get a specific system by SID and product description + 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 +``` + +### Output format +#### systems +- _Type:_ `list` of `dictionaries`
+ +A list of dictionaries, where each dictionary represents an SAP system.
+The product version ID may be returned under the 'Version' or 'Prodver' key, depending on the system's age and type. + +## Further Information +### Installation of prerequisites +**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
+ +Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +```yaml +--- +- name: Example play to install prerequisites for sap_launchpad + hosts: all + tasks: + - name: Install Python and Python package manager pip + ansible.builtin.package: + name: + - python311 + - python311-pip + state: present + + - name: Pre-Steps - Install Python modules to Python venv + ansible.builtin.pip: + name: + - wheel + - urllib3 + - requests + - beautifulsoup4 + - lxml + virtualenv: "/tmp/python_venv" + virtualenv_command: "python3.11 -m venv" +``` + +Prerequisite preparation using Python 3.11 system default
+```yaml +--- +- name: Example play to install prerequisites for sap_launchpad + hosts: all + tasks: + - name: Install Python and Python package manager pip + ansible.builtin.package: + name: + - python311 + - python311-pip + state: present + + - name: Install Python module packages + ansible.builtin.package: + name: + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present +``` +**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. + +## License +Apache 2.0 + +## Maintainers +Maintainers are shown within [/docs/contributors](./CONTRIBUTORS.md). + +## Module Variables +### suser_id +- _Required:_ `true`
+- _Type:_ `string`
+ +The SAP S-User ID with authorization to get System information. + +### suser_password +- _Required:_ `true`
+- _Type:_ `string`
+ +The password for the SAP S-User specified in `suser_id`. + +### filter +- _Type:_ `string`
+ +An OData filter expression to query the systems. From 6ca7690e6bac9256ea1ff0f222c0ee11f052ca2f Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 9 Sep 2025 11:38:31 +0200 Subject: [PATCH 3/9] refactor system_info and license_keys modules --- .../sap_launchpad_systems_runner.py | 400 ------------------ plugins/module_utils/systems/__init__.py | 1 + plugins/module_utils/systems/api.py | 321 ++++++++++++++ plugins/module_utils/systems/main.py | 185 ++++++++ plugins/modules/license_keys.py | 161 ++----- plugins/modules/systems_info.py | 62 ++- 6 files changed, 583 insertions(+), 547 deletions(-) delete mode 100644 plugins/module_utils/sap_launchpad_systems_runner.py create mode 100644 plugins/module_utils/systems/__init__.py create mode 100644 plugins/module_utils/systems/api.py create mode 100644 plugins/module_utils/systems/main.py diff --git a/plugins/module_utils/sap_launchpad_systems_runner.py b/plugins/module_utils/sap_launchpad_systems_runner.py deleted file mode 100644 index 42ad82a..0000000 --- a/plugins/module_utils/sap_launchpad_systems_runner.py +++ /dev/null @@ -1,400 +0,0 @@ -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/module_utils/systems/__init__.py b/plugins/module_utils/systems/__init__.py new file mode 100644 index 0000000..67a78bd --- /dev/null +++ b/plugins/module_utils/systems/__init__.py @@ -0,0 +1 @@ +# This file makes the `systems` directory into a Python package. \ No newline at end of file diff --git a/plugins/module_utils/systems/api.py b/plugins/module_utils/systems/api.py new file mode 100644 index 0000000..0de1dae --- /dev/null +++ b/plugins/module_utils/systems/api.py @@ -0,0 +1,321 @@ +import json +import time + +from urllib.parse import urljoin +from requests.exceptions import HTTPError + +from .. import constants as C +from .. import exceptions + + +class InstallationNotFoundError(Exception): + def __init__(self, installation_nr, available_installations): + self.installation_nr = installation_nr + self.available_installations = available_installations + super().__init__(f"Installation number '{installation_nr}' not found. Available installations: {available_installations}") + + +class SystemNotFoundError(Exception): + def __init__(self, system_nr, details): + self.system_nr = system_nr + self.details = details + super().__init__(f"System with number '{system_nr}' not found. Details: {details}") + + +class ProductNotFoundError(Exception): + def __init__(self, product, available_products): + self.product = product + self.available_products = available_products + super().__init__(f"Product '{product}' not found. Available products: {available_products}") + + +class VersionNotFoundError(Exception): + def __init__(self, version, available_versions): + self.version = version + self.available_versions = available_versions + super().__init__(f"Version '{version}' not found. Available versions: {available_versions}") + + +class LicenseTypeInvalidError(Exception): + def __init__(self, license_type, available_license_types): + self.license_type = license_type + self.available_license_types = available_license_types + super().__init__(f"License type '{license_type}' is invalid. Available types: {available_license_types}") + + +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 + super().__init__(f"Invalid data for {scope}: Unknown fields: {unknown_fields}, Missing required fields: {missing_required_fields}, Invalid options: {fields_with_invalid_option}") + + +def get_systems(client, filter_str): + # Retrieves a list of systems based on an OData filter string. + query_path = f"Systems?$filter={filter_str}" + return client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + + +def get_system(client, system_nr, installation_nr, username): + # Retrieves details for a single, specific system. + filter_str = f"Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '{system_nr}'" + try: + systems = get_systems(client, filter_str) + 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 SystemNotFoundError(system_nr, err.response.content) + else: + raise err + + if len(systems) == 0: + raise SystemNotFoundError(system_nr, "no systems returned by API") + + system = systems[0] + if 'Prodver' not in system and 'Version' not in system: + raise exceptions.SapLaunchpadError(f"System {system_nr} was found, but it is missing a required Product Version ID (checked for 'Prodver' and 'Version' keys). System details: {system}") + + return system + + +def get_product_id(client, product_name, installation_nr, username): + # Finds the internal product ID for a given product name. + query_path = f"SysProducts?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '' and Nocheck eq ''" + products = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + product = next((p for p in products if p['Description'] == product_name), None) + if product is None: + raise ProductNotFoundError(product_name, [p['Description'] for p in products]) + return product['Product'] + + +def get_version_id(client, version_name, product_id, installation_nr, username): + # Finds the internal version ID for a given product version name. + query_path = f"SysVersions?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Product eq '{product_id}' and Nocheck eq ''" + versions = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + version = next((v for v in versions if v['Description'] == version_name), None) + if version is None: + raise VersionNotFoundError(version_name, [v['Description'] for v in versions]) + return version['Version'] + + +def validate_installation(client, installation_nr, username): + # Checks if the user has access to the specified installation number. + query_path = f"Installations?$filter=Ubname eq '{username}' and ValidateOnly eq ''" + installations = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + if not any(i['Insnr'] == installation_nr for i in installations): + raise InstallationNotFoundError(installation_nr, [i['Insnr'] for i in installations]) + + +def validate_system_data(client, data, version_id, system_nr, installation_nr, username): + # Validates user-provided system data against the fields supported by the API for a given product version. + query_path = f"SystData?$filter=Pvnr eq '{version_id}' and Insnr eq '{installation_nr}'" + results = client.get(_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['Version'] = version_id + final_fields['Insnr'] = installation_nr + final_fields['Uname'] = username + final_fields['Sysnr'] = system_nr + final_fields_for_check = [{"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_for_check)}'" + results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + + warning = None + if len(results) > 0: + warning = json.loads(results[0]['Data'])[0]['VALUE'] + + final_fields_lower = [{"name": entry["name"].lower(), "value": entry["value"]} for entry in final_fields_for_check] + return final_fields_lower, warning + + +def validate_licenses(client, licenses, version_id, installation_nr, username): + # Validates user-provided license data against the license types and fields supported by the API. + query_path = f"LicenseType?$filter=PRODUCT eq '{version_id}' and INSNR eq '{installation_nr}' and Uname eq '{username}' and Nocheck eq 'X'" + results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + available_license_types = {r["LICENSETYPE"] for r in results} + license_data = [] + + for lic in licenses: + result = next((r for r in results if r["LICENSETYPE"] == lic['type']), None) + if result is None: + raise LicenseTypeInvalidError(lic['type'], available_license_types) + + final_fields = _validate_user_data_against_supported_fields(f'license {lic["type"]}', lic['data'], json.loads(result["Selfields"])) + 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(client, system_nr, username): + # Retrieves all existing license keys for a given system. + # When updating the licenses based on the results here, the backend expects a completely different format. + # This function transforms the response to the format the backend expects for subsequent update calls. + query_path = f"LicenseKeys?$filter=Uname eq '{username}' and Sysnr eq '{system_nr}'" + results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + return [ + { + "LICENSETYPETEXT": r["LicenseDescr"], "LICENSETYPE": r["Prodid"], "HWKEY": r["Hwkey"], + "EXPDATE": r["LidatC"], "STATUS": r["Status"], "STATUSCODE": r["StatusCode"], + "KEYNR": r["Keynr"], "QUANTITY": r["Ulimit"], "QUANTITY_C": r["UlimitC"], + "MAXEXPDATE": r["MaxLiDat"] + } for r in results + ] + + +def generate_licenses(client, license_data, existing_licenses, version_id, installation_nr, username): + # Generates new license keys for a system. + body = { + "Prodver": version_id, "ActionCode": "add", "ExistingData": json.dumps(existing_licenses), + "Entry": json.dumps(license_data), "Nocheck": "", "Insnr": installation_nr, "Uname": username + } + token = _get_csrf_token(client) + post_headers = _headers({ + 'x-csrf-token': token, + 'X-Requested-With': 'XMLHttpRequest' + }) + response = client.post(_url("BSHWKEY"), json=body, headers=post_headers).json() + return json.loads(response['d']['Result']) + + +def submit_system(client, is_new, system_data, generated_licenses, username): + # Submits all system and license data to create or update a system. + # The SAP Backend requires a completely different format for the license data (`matdata`) + # than what it returns from the GET request, so we map it here. + body = { + "actcode": "add" if is_new else "edit", "Uname": username, "sysdata": json.dumps(system_data), + "matdata": json.dumps([ + { + "hwkey": lic["HWKEY"], "prodid": lic["LICENSETYPE"], "quantity": lic["QUANTITY"], + "keynr": lic["KEYNR"], "expdat": lic["EXPDATE"], "status": lic["STATUS"], + "statusCode": lic["STATUSCODE"], + } for lic in generated_licenses + ]) + } + token = _get_csrf_token(client) + post_headers = _headers({ + 'x-csrf-token': token, + 'X-Requested-With': 'XMLHttpRequest' + }) + response = client.post(_url("Submit"), json=body, headers=post_headers).json() + licdata = json.loads(response['d']['licdata']) + if not licdata: + raise exceptions.SapLaunchpadError( + "The API call to submit the system was successful, but the response did not contain the expected system number. " + f"The 'licdata' field in the API response was empty: {response['d']['licdata']}" + ) + return licdata[0]['VALUE'] + + +def get_license_key_numbers(client, license_data, system_nr, username): + # Retrieves the unique key numbers for a list of recently created licenses. + key_nrs = [] + for lic in license_data: + query_path = f"LicenseKeys?$filter=Uname eq '{username}' and Sysnr eq '{system_nr}' and Prodid eq '{lic['LICENSETYPE']}' and Hwkey eq '{lic['HWKEY']}'" + + # Retry logic to handle potential replication delay in the backend API after a license is submitted. + for attempt in range(9): + results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] + if results: + key_nrs.append(results[0]['Keynr']) + break # Found it, break the retry loop + + if attempt < 8: # Don't sleep on the last attempt + time.sleep(10) # Wait 10 seconds before retrying + else: # This 'else' belongs to the 'for' loop, it runs if the loop completes without a 'break' + raise exceptions.SapLaunchpadError( + f"Could not find license key number for license type '{lic['LICENSETYPE']}' and HW key '{lic['HWKEY']}' " + f"on system '{system_nr}' after submitting the changes. There might be a replication delay in the SAP backend." + ) + + return key_nrs + + +def download_licenses(client, key_nrs): + # Downloads the license key file content for a list of key numbers. + keys_json = json.dumps([{"Keynr": key_nr} for key_nr in key_nrs]) + return client.get(_url(f"FileContent(Keynr='{keys_json}')/$value")).content + + +def delete_licenses(client, licenses_to_delete, existing_licenses, version_id, installation_nr, username): + # Deletes a list of specified licenses from a system. + body = { + "Prodver": version_id, "ActionCode": "delete", "ExistingData": json.dumps(existing_licenses), + "Entry": json.dumps(licenses_to_delete), "Nocheck": "", "Insnr": installation_nr, "Uname": username + } + token = _get_csrf_token(client) + post_headers = _headers({ + 'x-csrf-token': token, + 'X-Requested-With': 'XMLHttpRequest' + }) + response = client.post(_url("BSHWKEY"), json=body, headers=post_headers).json() + return json.loads(response['d']['Result']) + + +def _url(query_path): + # Helper to construct the full URL for the systems provisioning service. + return f'{C.URL_SYSTEMS_PROVISIONING}/{query_path}' + + +def _headers(additional_headers): + # Helper to construct standard request headers. + return {**{'Accept': 'application/json'}, **additional_headers} + + +def _get_csrf_token(client): + # Fetches the CSRF token required for POST/write operations. + # Add Origin and a more specific Referer header, as the service may require them to issue a CSRF token. + license_key_app_url = urljoin(C.URL_LAUNCHPAD, '/#/licensekey') + csrf_headers = _headers({ + 'x-csrf-token': 'Fetch', + 'Origin': C.URL_LAUNCHPAD, + 'Referer': license_key_app_url + }) + res = client.get(_url(''), headers=csrf_headers) + + # The CSRF token is primarily expected in the 'x-csrf-token' header. + token = res.headers.get('x-csrf-token') + + # As a fallback, check if the token was already set in a cookie by a previous + # request. The cookie name can vary in case. + if not token: + cookies = client.get_cookies() + token = cookies.get('X-CSRF-Token') or cookies.get('x-csrf-token') or cookies.get('__HOST-XSRF_COOKIE') + + if not token: + raise exceptions.SapLaunchpadError( + "Failed to retrieve CSRF token. The API did not return the 'x-csrf-token' header or a CSRF cookie." + ) + return token + + +def _validate_user_data_against_supported_fields(scope, user_data, possible_fields): + # A generic helper to validate a dictionary of user data against a list of API-supported fields. + unknown_fields = {field for field in user_data if not any(field == pf['FIELD'] for pf in possible_fields)} + missing_required_fields = {} + fields_with_invalid_option = {} + final_fields = {} + + for pf in possible_fields: + user_value = user_data.get(pf["FIELD"]) + if user_value is not None: + if len(pf["DATA"]) == 0: + final_fields[pf["FIELD"]] = user_value + else: + resolved_value = next((entry["NAME"] for entry in pf["DATA"] if entry['VALUE'] == user_value), None) + if resolved_value is None: + fields_with_invalid_option[pf["FIELD"]] = [d['VALUE'] for d in pf["DATA"]] + else: + final_fields[pf["FIELD"]] = resolved_value + elif pf['REQUIRED'] == "X": + missing_required_fields[pf["FIELD"]] = [d['VALUE'] for d in pf["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 \ No newline at end of file diff --git a/plugins/module_utils/systems/main.py b/plugins/module_utils/systems/main.py new file mode 100644 index 0000000..0060d8a --- /dev/null +++ b/plugins/module_utils/systems/main.py @@ -0,0 +1,185 @@ +import os +import pathlib +from requests.exceptions import HTTPError + +from .. import auth, exceptions +from ..client import ApiClient +from . import api + + +def run_systems_info(params): + # Main runner function for the systems_info module. + result = {'changed': False, 'failed': False, 'systems': []} + client = ApiClient() + try: + auth.login(client, params['suser_id'], params['suser_password']) + result['systems'] = api.get_systems(client, params['filter']) + except (exceptions.SapLaunchpadError, api.SystemNotFoundError) as e: + result['failed'] = True + result['msg'] = str(e) + return result + + +def run_license_keys(params): + # Main runner function for the license_keys module. + result = {'changed': False, 'failed': False, 'warnings': []} + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + installation_nr = params['installation_nr'] + system_nr = params['system_nr'] + state = params['state'] + + try: + auth.login(client, username, password) + api.validate_installation(client, installation_nr, username) + + # If system_nr is not provided, try to find it using the SID for idempotency. + if not system_nr: + system_data_params = params.get('system_data', {}) + sid = system_data_params.get('sysid') + if sid: + filter_str = f"Insnr eq '{installation_nr}' and sysid eq '{sid}'" + existing_systems = api.get_systems(client, filter_str) + if len(existing_systems) == 1: + system_nr = existing_systems[0]['Sysnr'] + result['warnings'].append(f"A system with SID '{sid}' already exists. Using system number {system_nr} for update.") + elif len(existing_systems) > 1: + # Ambiguous situation: multiple systems with the same SID. + # Force user to provide system_nr to select one. + system_nrs_found = [s['Sysnr'] for s in existing_systems] + result['failed'] = True + result['msg'] = (f"Multiple systems with SID '{sid}' found under installation '{installation_nr}': " + f"{', '.join(system_nrs_found)}. Please provide a specific 'system_nr' to select which system to update.") + return result + + is_new_system = not system_nr + if is_new_system: + if state == 'absent': + result['msg'] = "Cannot ensure absence of a new system; system_nr is required." + result['failed'] = True + return result + + product_id = api.get_product_id(client, params['product_name'], installation_nr, username) + version_id = api.get_version_id(client, params['product_version'], product_id, installation_nr, username) + + system_data, warning = api.validate_system_data(client, params['system_data'], version_id, system_nr, installation_nr, username) + if warning: + result['warnings'].append(warning) + + license_data = api.validate_licenses(client, params['licenses'], version_id, installation_nr, username) + generated_licenses = api.generate_licenses(client, license_data, [], version_id, installation_nr, username) + system_nr = api.submit_system(client, True, system_data, generated_licenses, username) + + result['changed'] = True + result['system_nr'] = system_nr + result['msg'] = f"System {system_nr} created successfully." + + else: # Existing system + system = api.get_system(client, system_nr, installation_nr, username) + # The API has been observed to return the version ID under the 'Version' key for existing systems. + # We check for 'Version' first, then fall back to 'Prodver' for compatibility. + version_id = system.get('Version') or system.get('Prodver') + if not version_id: + raise exceptions.SapLaunchpadError(f"System {system_nr} is missing a required Product Version ID.") + existing_licenses = api.get_existing_licenses(client, system_nr, username) + + # The API requires a sysdata payload even for an edit operation. + # It must contain at least the installation number, system number, product version, and system ID. + sysid = system.get('sysid') + if not sysid: + raise exceptions.SapLaunchpadError(f"System {system_nr} is missing a required System ID ('sysid').") + + systype = system.get('systype') + if not systype: + raise exceptions.SapLaunchpadError(f"System {system_nr} is missing a required System Type ('systype').") + + sysdata_for_edit = [ + {"name": "insnr", "value": installation_nr}, + {"name": "sysnr", "value": system_nr}, + {"name": "prodver", "value": version_id}, + {"name": "sysid", "value": sysid}, + {"name": "systype", "value": systype} + ] + + if state == 'present': + user_licenses = params.get('licenses') + if not user_licenses: + result['msg'] = "System already present. No licenses specified to update." + return result + + license_data = api.validate_licenses(client, user_licenses, version_id, installation_nr, username) + new_or_changed = [l for l in license_data if not any(l['HWKEY'] == el['HWKEY'] and l['LICENSETYPE'] == el['LICENSETYPE'] for el in existing_licenses)] + + if not new_or_changed: + result['msg'] = "System and licenses are already in the desired state." + return result + + generated = api.generate_licenses(client, new_or_changed, existing_licenses, version_id, installation_nr, username) + api.submit_system(client, False, sysdata_for_edit, generated, username) + result['changed'] = True + result['msg'] = f"System {system_nr} licenses updated successfully." + + elif state == 'absent': + user_licenses_to_keep = params.get('licenses', []) + if not user_licenses_to_keep: # Delete all licenses + licenses_to_delete = existing_licenses + else: + validated_to_keep = api.validate_licenses(client, user_licenses_to_keep, version_id, installation_nr, username) + key_nrs_to_keep = [l['KEYNR'] for l in existing_licenses if any(k['HWKEY'] == l['HWKEY'] and k['LICENSETYPE'] == l['LICENSETYPE'] for k in validated_to_keep)] + licenses_to_delete = [l for l in existing_licenses if l['KEYNR'] not in key_nrs_to_keep] + + if not licenses_to_delete: + result['msg'] = "All specified licenses are already absent or were not present." + return result + + deleted_licenses = api.delete_licenses(client, licenses_to_delete, existing_licenses, version_id, installation_nr, username) + api.submit_system(client, False, sysdata_for_edit, deleted_licenses, username) + result['changed'] = True + result['msg'] = f"Successfully deleted licenses from system {system_nr}." + + # Download/return license file content if applicable + if state == 'present': + user_licenses = params.get('licenses') + if user_licenses: + validated_licenses = api.validate_licenses(client, user_licenses, version_id, installation_nr, username) + key_nrs = api.get_license_key_numbers(client, validated_licenses, system_nr, username) + content_bytes = api.download_licenses(client, key_nrs) + content_str = content_bytes.decode('utf-8') + + result['license_file'] = content_str + + if params.get('download_path'): + dest_path = pathlib.Path(params['download_path']) + if not dest_path.is_dir(): + result['failed'] = True + result['msg'] = f"Destination for license file does not exist or is not a directory: {dest_path}" + return result + + output_file = dest_path / f"{system_nr}_licenses.txt" + try: + with open(output_file, 'w', encoding='utf-8') as f: + f.write(content_str) + + current_msg = result.get('msg', '') + download_msg = f"License file downloaded to {output_file}." + result['msg'] = f"{current_msg} {download_msg}".strip() + except IOError as e: + result['failed'] = True + result['msg'] = f"Failed to write license file: {e}" + + except (exceptions.SapLaunchpadError, + api.InstallationNotFoundError, + api.SystemNotFoundError, + api.ProductNotFoundError, + api.VersionNotFoundError, + api.LicenseTypeInvalidError, + api.DataInvalidError, + ValueError) as e: + result['failed'] = True + result['msg'] = str(e) + except Exception as e: + result['failed'] = True + result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" + + return result \ No newline at end of file diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index ce25cd4..344b976 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -1,8 +1,8 @@ -from ansible.module_utils.basic import AnsibleModule +#!/usr/bin/python -from ..module_utils.sap_launchpad_systems_runner import * -from ..module_utils.sap_id_sso import sap_sso_login +from __future__ import absolute_import, division, print_function +__metaclass__ = type DOCUMENTATION = r''' --- @@ -31,6 +31,7 @@ - SAP S-User Password. required: true type: str + no_log: true installation_nr: description: - Number of the Installation for which the system should be created/updated @@ -93,8 +94,11 @@ type: bool required: false default: false - - + download_path: + description: If specified, the generated license key file will be downloaded to this directory. + required: false + type: path + author: - Lab for SAP Solutions @@ -132,18 +136,17 @@ - name: Display the license file containing the licenses debug: - msg: - - "{{ result.license_file }}" + var: 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 + The license file content containing the digital signatures of the specified licenses. + This is returned when C(state) is 'present' and licenses are specified. + returned: on success + type: str sample: | ----- Begin SAP License ----- SAPSYSTEM=H01 @@ -165,14 +168,16 @@ 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 + returned: on success + type: str + sample: "0000123456" ''' +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.systems import main as systems_runner + def run_module(): # Define available arguments/parameters a user can pass to the module @@ -182,127 +187,53 @@ def run_module(): installation_nr=dict(type='str', required=True), system=dict( type='dict', + required=True, options=dict( nr=dict(type='str', required=False), product=dict(type='str', required=True), version=dict(type='str', required=True), - data=dict(type='dict') + data=dict(type='dict', required=True) ) ), licenses=dict(type='list', required=True, elements='dict', options=dict( type=dict(type='str', required=True), - data=dict(type='dict'), + data=dict(type='dict', required=True), )), delete_other_licenses=dict(type='bool', required=False, default=False), + download_path=dict(type='path', required=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 + supports_check_mode=True ) - 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 module.check_mode: + module.exit_json(changed=False, msg="Check mode not supported for license key management.") - 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) + # Translate original parameters to the new, flat structure for the runner. + params = module.params.copy() - if len(new_or_changed_license_data) > 0: - generated_licenses = generate_licenses(new_or_changed_license_data, existing_licenses, version_id, - installation_nr, username) + # The runner expects a flat structure, so we unpack the 'system' dictionary. + system_info = params.pop('system') + params['system_nr'] = system_info.get('nr') + params['product_name'] = system_info.get('product') + params['product_version'] = system_info.get('version') + params['system_data'] = system_info.get('data') - 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 + # The runner uses a 'state' parameter instead of 'delete_other_licenses'. + if params.pop('delete_other_licenses', False): + params['state'] = 'absent' + else: + params['state'] = 'present' - 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) + # Call the runner with the translated parameters. + result = systems_runner.run_license_keys(params) - module.exit_json(**result) + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) def main(): diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index 6c60003..be25d5f 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -1,19 +1,16 @@ -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 +#!/usr/bin/python -from requests.exceptions import HTTPError +from __future__ import absolute_import, division, print_function DOCUMENTATION = r''' --- module: systems_info -short_description: Queries registered systems in me.sap.com +short_description: Retrieves information about SAP systems. 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) +- This module queries the SAP Launchpad to retrieve a list of registered systems based on a filter string. +- It allows for fetching details about systems associated with the authenticated S-User. version_added: 1.1.0 @@ -28,39 +25,51 @@ - SAP S-User Password. required: true type: str + no_log: true filter: description: - An ODATA filter expression to query the systems. required: true type: str author: - - Lab for SAP Solutions + - SAP LinuxLab ''' EXAMPLES = r''' -- name: get system by SID and product +- name: Get all systems for a specific installation number 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'" + filter: "Insnr eq '1234567890'" register: result -- name: Display the first returned system +- name: Display system details debug: - msg: - - "{{ result.systems[0] }}" + var: result.systems ''' RETURN = r''' systems: - description: the systems returned for the filter + description: + - A list of dictionaries, where each dictionary represents an SAP system. + - The product version ID may be returned under the 'Version' or 'Prodver' key, depending on the system's age and type. returned: always type: list + elements: dict + sample: + - Sysnr: "0000123456" + Sysid: "S4H" + Systxt: "S/4HANA Development System" + Insnr: "1234567890" + Version: "73554900100800000266" ''' +from ansible.module_utils.basic import AnsibleModule +from ..module_utils.systems import main as systems_runner + def run_module(): module_args = dict( @@ -69,28 +78,17 @@ def run_module(): filter=dict(type='str', required=True), ) - result = dict( - systems='', - ) - module = AnsibleModule( argument_spec=module_args, - supports_check_mode=False + supports_check_mode=True ) - 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) + result = systems_runner.run_systems_info(module.params) - module.exit_json(**result) + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) def main(): From 6de1cb54883b578061b56ae210a39134504f69ca Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 9 Sep 2025 11:39:29 +0200 Subject: [PATCH 4/9] refactor maintenance planner modules and update software_center --- plugins/module_utils/auth.py | 13 +- plugins/module_utils/constants.py | 13 +- .../maintenance_planner/__init__.py | 1 + .../module_utils/maintenance_planner/api.py | 202 +++++++++ .../module_utils/maintenance_planner/main.py | 101 +++++ ...ap_launchpad_maintenance_planner_runner.py | 384 ------------------ ...aunchpad_software_center_catalog_runner.py | 12 - .../module_utils/software_center/__init__.py | 2 +- .../module_utils/software_center/download.py | 48 ++- plugins/module_utils/software_center/main.py | 29 +- .../module_utils/software_center/search.py | 28 +- plugins/modules/maintenance_planner_files.py | 111 ++--- .../maintenance_planner_stack_xml_download.py | 85 ++-- plugins/modules/software_center_download.py | 44 +- 14 files changed, 481 insertions(+), 592 deletions(-) create mode 100644 plugins/module_utils/maintenance_planner/__init__.py create mode 100644 plugins/module_utils/maintenance_planner/api.py create mode 100644 plugins/module_utils/maintenance_planner/main.py delete mode 100644 plugins/module_utils/sap_launchpad_maintenance_planner_runner.py delete mode 100644 plugins/module_utils/sap_launchpad_software_center_catalog_runner.py diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 3d52bb3..5c26609 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -29,7 +29,7 @@ def login(client, username, password): meta = {} while ('SAMLResponse' not in meta and 'login_hint' not in meta): - endpoint, meta = _get_sso_endpoint_meta(client, endpoint, data=meta) + endpoint, meta = get_sso_endpoint_meta(client, endpoint, data=meta) if 'j_username' in meta: meta['j_username'] = username meta['j_password'] = password @@ -37,7 +37,7 @@ def login(client, username, password): raise ValueError('SAP ID Service has requested `Change Your Password`, possibly the password is too old. Please reset manually and try again.') if 'authn' in endpoint: - support_endpoint, support_meta = _get_sso_endpoint_meta(client, endpoint, data=meta) + support_endpoint, support_meta = get_sso_endpoint_meta(client, endpoint, data=meta) client.post(support_endpoint, data=support_meta) if 'gigya' in endpoint: @@ -56,12 +56,12 @@ def login(client, username, password): 'loginToken': login_token, 'samlContext': params['samlContext'] } - endpoint, meta = _get_sso_endpoint_meta(client, idp_endpoint, + endpoint, meta = get_sso_endpoint_meta(client, idp_endpoint, params=context, allow_redirects=False) while (endpoint != C.URL_LAUNCHPAD + '/'): - endpoint, meta = _get_sso_endpoint_meta(client, endpoint, + endpoint, meta = get_sso_endpoint_meta(client, endpoint, data=meta, headers=C.GIGYA_HEADERS, allow_redirects=False) @@ -69,7 +69,7 @@ def login(client, username, password): client.post(endpoint, data=meta, headers=C.GIGYA_HEADERS) -def _get_sso_endpoint_meta(client, url, **kwargs): +def get_sso_endpoint_meta(client, url, **kwargs): # Scrapes an HTML page to find the next SSO form action URL and its input fields. method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET' res = client.request(method, url, **kwargs) @@ -213,8 +213,7 @@ def _get_sdk_build_number(client, api_key): if _GIGYA_SDK_BUILD_NUMBER is not None: return _GIGYA_SDK_BUILD_NUMBER - res = client.get('https://cdns.gigya.com/js/gigya.js', - params={'apiKey': api_key}) + res = client.get(C.URL_GIGYA_JS, params={'apiKey': api_key}) gigya_js_content = res.text match = re.search(r'gigya.build\s*=\s*{[\s\S]+"number"\s*:\s*(\d+),', gigya_js_content) if not match: diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index b4c8975..c916d05 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -1,19 +1,19 @@ # SAP Launchpad & Software Center URLs URL_LAUNCHPAD = 'https://launchpad.support.sap.com' URL_SOFTWARE_CENTER_SERVICE = 'https://launchpad.support.sap.com/services/odata/svt/swdcuisrv' -URL_SOFTWARE_CENTER_VERSION = 'https://launchpad.support.sap.com/applications/softwarecenter/version.json' -URL_SOFTWARE_CATALOG = 'https://launchpad.support.sap.com/applications/softwarecenter/~{v}~/model/ProductView.json' +# URL_SOFTWARE_CENTER_VERSION = 'https://launchpad.support.sap.com/applications/softwarecenter/version.json' +# URL_SOFTWARE_CATALOG = 'https://launchpad.support.sap.com/applications/softwarecenter/~{v}~/model/ProductView.json' URL_ACCOUNT_ATTRIBUTES = 'https://launchpad.support.sap.com/services/account/attributes' -URL_SERVICE_INCIDENT = 'https://launchpad.support.sap.com/services/odata/incidentws' +# 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' # Maintenance Planner URLs -URL_MAINTENANCE_PLANNER = 'https://maintenanceplanner.cfapps.eu10.hana.ondemand.com' +# 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' +# URL_LEGACY_MP_API = 'https://tech.support.sap.com/sap/support/mnp/services' # Gigya Authentication URLs # These URLs are part of the SAP Universal ID (Gigya) authentication flow. @@ -22,7 +22,8 @@ URL_ACCOUNT_CDC_API = 'https://cdc-api.account.sap.com' URL_ACCOUNT_SSO_IDP = 'https://cdc-api.account.sap.com/saml/v2.0/{k}/idp/sso/continue' URL_ACCOUNT_SAML_PROXY = 'https://account.sap.com/core/SAMLProxyPage.html' -URL_SUPPORT_PORTAL = 'https://hana.ondemand.com/supportportal' +URL_GIGYA_JS = 'https://cdns.gigya.com/js/gigya.js' +# URL_SUPPORT_PORTAL = 'https://hana.ondemand.com/supportportal' # HTTP Headers & User Agents USER_AGENT_CHROME = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) ' diff --git a/plugins/module_utils/maintenance_planner/__init__.py b/plugins/module_utils/maintenance_planner/__init__.py new file mode 100644 index 0000000..9b5afe6 --- /dev/null +++ b/plugins/module_utils/maintenance_planner/__init__.py @@ -0,0 +1 @@ +# This file makes the `maintenance_planner` directory into a Python package. \ No newline at end of file diff --git a/plugins/module_utils/maintenance_planner/api.py b/plugins/module_utils/maintenance_planner/api.py new file mode 100644 index 0000000..29e871e --- /dev/null +++ b/plugins/module_utils/maintenance_planner/api.py @@ -0,0 +1,202 @@ +import re +import time +from html import unescape +from urllib.parse import urljoin +from bs4 import BeautifulSoup +from lxml import etree + +from .. import constants as C +from .. import exceptions +from ..auth import get_sso_endpoint_meta + +# Module-level cache +_MP_XSRF_TOKEN = None +_MP_TRANSACTIONS = None +_MP_NAMESPACE = 'http://xml.sap.com/2012/01/mnp' + + +def auth_userapps(client): + # Authenticates against userapps.support.sap.com to establish a session. + _clear_mp_cookies(client, 'userapps') + + # Reset cache on re-authentication + global _MP_XSRF_TOKEN, _MP_TRANSACTIONS + _MP_XSRF_TOKEN = None + _MP_TRANSACTIONS = None + + endpoint, meta = get_sso_endpoint_meta(client, C.URL_USERAPPS) + + while endpoint != C.URL_USERAPPS: + endpoint, meta = get_sso_endpoint_meta(client, endpoint, data=meta) + + client.post(endpoint, data=meta) + + +def get_transactions(client): + # Retrieves a list of all available Maintenance Planner transactions. + global _MP_TRANSACTIONS + if _MP_TRANSACTIONS is not None: + return _MP_TRANSACTIONS + + res = _mp_request(client, params={'action': 'getTransactions'}) + xml = unescape(res.text.replace('\ufeff', '')) + doc = BeautifulSoup(xml, features='lxml') + transactions = [t.attrs for t in doc.find_all('mnp:transaction')] + + if not transactions: + raise exceptions.FileNotFoundError("No Maintenance Planner transactions found for this user.") + + _MP_TRANSACTIONS = transactions + return _MP_TRANSACTIONS + + +def get_transaction_id(client, name): + # Finds a transaction ID by its name or display ID. + transactions = get_transactions(client) + + # Search by transaction name + for t in transactions: + if t.get('trans_name') == name: + return t['trans_id'] + + # If not found, search by display ID + for t in transactions: + if t.get('trans_display_id') == name: + return t['trans_id'] + + raise exceptions.FileNotFoundError(f"Transaction '{name}' not found by name or display ID.") + + +def get_transaction_filename_url(client, trans_id): + # Parses the files XML to get a list of (URL, Filename) tuples. + xml = _get_download_files_xml(client, trans_id) + e = etree.fromstring(xml.encode('utf-16')) + stack_files = e.xpath( + '//mnp:entity[@id="stack_files"]/mnp:entity', + namespaces={'mnp': _MP_NAMESPACE} + ) + if not stack_files: + raise exceptions.FileNotFoundError(f"No stack files found in transaction ID {trans_id}.") + + files = [] + for f in stack_files: + file_id = urljoin(C.URL_SOFTWARE_DOWNLOAD, '/file/' + f.get('id')) + file_name = f.get('label') + files.append((file_id, file_name)) + return files + + +def get_transaction_stack_xml_content(client, trans_id): + # Downloads the stack XML file content for a transaction. + # The response contains an XML file with XML Element values using appropriate special character predefined entities (e.g. & instead of &). + params = { + 'action': 'downloadFiles', + 'sub_action': 'stack-plan', + 'session_id': trans_id, + } + res = _mp_request(client, params=params) + + filename = None + content_disposition = res.headers.get('content-disposition') + if content_disposition: + match = re.search(r'filename="?([^"]+)"?', content_disposition) + if match: + filename = match.group(1) + + return res.text, filename + + +def _mp_request(client, **kwargs): + # A wrapper for making requests to the MP service, handling timestamps, + # XSRF tokens, and re-authentication. + params = kwargs.get('params', {}).copy() + params['_'] = int(time.time() * 1000) + kwargs['params'] = params + + method = 'POST' if 'data' in kwargs or 'json' in kwargs else 'GET' + + headers = kwargs.get('headers', {}).copy() + if params.get('action') != 'getInitialData': + headers['xsrf-token'] = _get_xsrf_token(client) + kwargs['headers'] = headers + + if 'allow_redirects' not in kwargs: + kwargs['allow_redirects'] = False + + def do_request(): + return client.request(method, C.URL_USERAPP_MP_SERVICE, **kwargs) + + res = do_request() + + if (res.status_code == 302 and res.headers.get('location', '').startswith(C.URL_ACCOUNT)): + # Session for userapps has expired, re-authenticate and retry. + auth_userapps(client) + res = do_request() + + return res + + +def _get_xsrf_token(client): + # Fetches and caches the XSRF token required for MP requests. + global _MP_XSRF_TOKEN + if _MP_XSRF_TOKEN: + return _MP_XSRF_TOKEN + + res = _mp_request(client, params={'action': 'getInitialData'}) + + token = res.headers.get('xsrf-token') + if not token: + raise exceptions.SapLaunchpadError("Failed to get XSRF token for Maintenance Planner.") + + _MP_XSRF_TOKEN = token + return _MP_XSRF_TOKEN + + +def _get_download_files_xml(client, trans_id): + # Fetches the XML defining the files for a given transaction. + trans_name = _get_transaction(client, 'trans_id', trans_id)['trans_name'] + request_xml = _build_mnp_xml( + action='postProcessStack', + call_for='download_stack_xml', + sessionid=trans_id, + trans_name=trans_name + ) + res = _mp_request(client, data=request_xml) + xml = unescape(res.text.replace('\ufeff', '')) + return xml + + +def _get_transaction(client, key, value): + # Helper to find a single transaction by a specific key-value pair. + transactions = get_transactions(client) + for t in transactions: + if t.get(key) == value: + return t + raise exceptions.FileNotFoundError(f"Transaction with {key}='{value}' not found.") + + +def _build_mnp_xml(**params): + # Constructs the MNP XML payload for API requests. + mnp = f'{{{_MP_NAMESPACE}}}' + + request_keys = ['action', 'trans_name', 'sub_action', 'navigation'] + request_attrs = {k: params.get(k, '') for k in request_keys} + + entity_keys = ['call_for', 'sessionid'] + entity_attrs = {k: params.get(k, '') for k in entity_keys} + + request = etree.Element(f'{mnp}request', nsmap={"mnp": _MP_NAMESPACE}, attrib=request_attrs) + entity = etree.SubElement(request, f'{mnp}entity', attrib=entity_attrs) + entity.text = '' + + if 'entities' in params and isinstance(params['entities'], etree._Element): + entity.append(params['entities']) + + return etree.tostring(request, pretty_print=False) + + +def _clear_mp_cookies(client, startswith): + # Clears cookies for a specific domain prefix from the client session. + for cookie in client.session.cookies: + if cookie.domain.startswith(startswith): + client.session.cookies.clear(domain=cookie.domain) \ No newline at end of file diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py new file mode 100644 index 0000000..198de8b --- /dev/null +++ b/plugins/module_utils/maintenance_planner/main.py @@ -0,0 +1,101 @@ +import pathlib + +from .. import auth, exceptions +from ..client import ApiClient +from . import api +from requests.exceptions import HTTPError + + +def run_files(params): + # Runner for maintenance_planner_files module. + result = dict( + download_basket={}, + changed=False, + msg='' + ) + + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + transaction_name = params['transaction_name'] + validate_url = params['validate_url'] + + try: + auth.login(client, username, password) + api.auth_userapps(client) + + transaction_id = api.get_transaction_id(client, transaction_name) + download_basket_details = api.get_transaction_filename_url(client, transaction_id) + + if validate_url: + for pair in download_basket_details: + url = pair[0] + try: + client.head(url) + except HTTPError: + raise exceptions.DownloadError(f'Download link is not available: {url}') + + result['download_basket'] = [{'DirectLink': i[0], 'Filename': i[1]} for i in download_basket_details] + result['changed'] = True + result['msg'] = "Successfully retrieved file list from SAP Maintenance Planner." + + except (exceptions.SapLaunchpadError, HTTPError) as e: + result['failed'] = True + result['msg'] = str(e) + except Exception as e: + result['failed'] = True + result['msg'] = f"An unexpected error occurred: {e}" + + return result + + +def run_stack_xml_download(params): + # Runner for maintenance_planner_stack_xml_download module. + result = dict( + changed=False, + msg='' + ) + + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + transaction_name = params['transaction_name'] + dest = params['dest'] + + try: + auth.login(client, username, password) + api.auth_userapps(client) + + transaction_id = api.get_transaction_id(client, transaction_name) + xml_content, filename = api.get_transaction_stack_xml_content(client, transaction_id) + + if not filename: + filename = f"{transaction_name}_stack.xml" + + dest_path = pathlib.Path(dest) + if not dest_path.is_dir(): + result['failed'] = True + result['msg'] = f"Destination directory does not exist: {dest}" + return result + + output_file = dest_path / filename + + try: + with open(output_file, 'w', encoding='utf-8') as f: + f.write(xml_content) + except IOError as e: + result['failed'] = True + result['msg'] = f"Failed to write to destination file {output_file}: {e}" + return result + + result['changed'] = True + result['msg'] = f"SAP Maintenance Planner Stack XML successfully downloaded to {output_file}" + + except (exceptions.SapLaunchpadError, HTTPError) as e: + result['failed'] = True + result['msg'] = str(e) + except Exception as e: + result['failed'] = True + result['msg'] = f"An unexpected error occurred: {e}" + + return result \ No newline at end of file diff --git a/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py b/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py deleted file mode 100644 index 81a9679..0000000 --- a/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py +++ /dev/null @@ -1,384 +0,0 @@ -import pathlib -import re -import time -from html import unescape -from urllib.parse import urljoin - -from bs4 import BeautifulSoup -from lxml import etree -from requests.auth import HTTPBasicAuth - -from . import constants as C -from .sap_api_common import _request, https_session -from .sap_id_sso import _get_sso_endpoint_meta - -_MP_XSRF_TOKEN = None -_MP_TRANSACTIONS = None - - -def auth_maintenance_planner(): - # Clear mp relevant cookies for avoiding unexpected responses. - _clear_mp_cookies('maintenanceplanner') - res = _request(C.URL_MAINTENANCE_PLANNER) - sig_re = re.compile('signature=(.*?);path=\/";location="(.*)"') - signature, redirect = re.search(sig_re, res.text).groups() - - # Essential cookies for the final callback - mp_cookies = { - 'signature': signature, - 'fragmentAfterLogin': '', - 'locationAfterLogin': '%2F' - } - - 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='/') - - res = _request(redirect) - meta_re = re.compile('') - raw_redirect = re.search(meta_re, res.text).group(1) - - endpoint = urljoin(res.url, unescape(raw_redirect)) - meta = {} - while 'SAMLResponse' not in meta: - endpoint, meta = _get_sso_endpoint_meta(endpoint, data=meta) - _request(endpoint, data=meta) - - -def auth_userapps(): - """Auth against userapps.support.sap.com - """ - _clear_mp_cookies('userapps') - endpoint, meta = _get_sso_endpoint_meta(C.URL_USERAPPS) - - while endpoint != C.URL_USERAPPS: - endpoint, meta = _get_sso_endpoint_meta(endpoint, data=meta) - _request(endpoint, data=meta) - - # Reset Cache - global _MP_XSRF_TOKEN - global _MP_TRANSACTIONS - _MP_XSRF_TOKEN = None - _MP_TRANSACTIONS = None - - -def get_mp_user_details(): - url = urljoin(C.URL_MAINTENANCE_PLANNER, - '/MCP/MPHomePageController/getUserDetailsDisplay') - params = {'_': int(time.time() * 1000)} - user = _request(url, params=params).json() - return user - - -def get_transactions(): - global _MP_TRANSACTIONS - if _MP_TRANSACTIONS is not None: - return _MP_TRANSACTIONS - res = _mp_request(params={'action': 'getTransactions'}) - xml = unescape(res.text.replace('\ufeff', '')) - doc = BeautifulSoup(xml, features='lxml') - _MP_TRANSACTIONS = [t.attrs for t in doc.find_all('mnp:transaction')] - return _MP_TRANSACTIONS - - -def get_transaction_details(trans_id): - params = { - 'action': 'getMaintCycle', - 'sub_action': 'load', - 'call_from': 'transactions', - 'session_id': trans_id - } - res = _mp_request(params=params) - xml = unescape(res.text.replace('\ufeff', '')) - return xml - - -def get_transaction_stack_xml(trans_id, output_dir=None): - params = { - 'action': 'downloadFiles', - 'sub_action': 'stack-plan', - 'session_id': trans_id, - } - - # Returns XML file with XML Element values using appropriate special character predefined entities (e.g. & instead of &) - res = _mp_request(params=params) - - if output_dir is None: - return res.text - - dest = pathlib.Path(output_dir) - # content-disposition: attachment; filename=MP_XX_STACK.xml - _, name = res.headers.get('content-disposition').split('filename=') - dest = dest.joinpath(name) - - with open(dest, 'w') as f: - f.write(res.text) - - -def get_stack_files_xml(trans_id): - trans_name = _get_transaction_name(trans_id) - request_xml = _build_mnp_xml(action='getStackFiles', - call_for='download_stack_xml', - sessionid=trans_id, - trans_name=trans_name) - - res = _mp_request(data=request_xml) - xml = unescape(res.text.replace('\ufeff', '')) - return xml - - -def get_download_files_xml(trans_id): - trans_name = _get_transaction_name(trans_id) - request_xml = _build_mnp_xml(action='postProcessStack', - call_for='download_stack_xml', - sessionid=trans_id, - trans_name=trans_name) - res = _mp_request(data=request_xml) - xml = unescape(res.text.replace('\ufeff', '')) - return xml - - -def get_download_basket_files(trans_id): - params = { - 'action': 'getDownloadBasketFiles', - 'session_id': trans_id, - } - res = _mp_request(params=params) - xml = unescape(res.text.replace('\ufeff', '')) - return xml - - -def add_stack_download_files_to_basket(trans_id): - ''' - POST data formart: - - - - - - - - - - ''' - params = { - 'action': 'push2Db', - 'session_id': trans_id, - } - xml = get_download_files_xml(trans_id) - doc = etree.fromstring(xml.encode('utf-16')) - stack_files = doc.xpath( - '//mnp:entity[@id="stack_files"]', - namespaces={'mnp': 'http://xml.sap.com/2012/01/mnp'}) - if not stack_files: - raise ValueError('stack files not found') - - request_xml = _build_mnp_xml(action='push2Db', - call_for='download_stack_xml', - sessionid=trans_id, - entities=stack_files[0]) - res = _mp_request(params=params, data=request_xml) - xml = unescape(res.text.replace('\ufeff', '')) - return xml - - -def get_download_basket_url_filename(): - download_items = get_download_basket_json() - return [(i['DirectDownloadUrl'], i['ObjectName']) for i in download_items] - - -def get_download_basket_json(): - url = C.URL_SOFTWARE_CENTER_SERVICE + '/DownloadBasketItemSet' - headers = {'Accept': 'application/json'} - j = _request(url, headers=headers).json() - - results = j['d']['results'] - for r in results: - r.pop('__metadata', None) - return results - - -def get_transaction_id_by_name(name): - transaction = _get_transaction('trans_name', name) - return transaction['trans_id'] - - -def get_transaction_id_by_display_id(display_id): - transaction = _get_transaction('trans_display_id', display_id) - return transaction['trans_id'] - -def get_transaction_filename_url(trans_id): - xml = get_download_files_xml(trans_id) - e = etree.fromstring(xml.encode('utf-16')) - stack_files = e.xpath( - '//mnp:entity[@id="stack_files"]/mnp:entity', - namespaces={'mnp': 'http://xml.sap.com/2012/01/mnp'}) - files = [] - for f in stack_files: - file_id = C.URL_SOFTWARE_DOWNLOAD + '/file/' + f.get('id') - file_name = f.get('label') - files.append((file_id, file_name)) - return files - -def fetch_download_files(display_id): - params = { - 'action': 'fetchFile', - 'sub_action': 'download_xml', - 'display_id': display_id, - } - - res = _mp_request(params=params) - xml = unescape(res.text.replace('\ufeff', '')) - e = etree.fromstring(xml.encode('utf-8')) - files = e.xpath('./download/files/file') - url_filename_list = [(f.find('url').text, f.find('name').text) - for f in files] - - return url_filename_list - - -def clear_download_basket(): - download_items = get_download_basket_json() - for item in download_items: - object_id = item['ObjectKey'] - delete_item_in_download_basket(object_id) - - -def delete_item_in_download_basket(object_id): - url = C.URL_SOFTWARE_CENTER_SERVICE + '/DownloadContentSet' - params = { - '_MODE': 'OBJDEL', - 'OBJID': object_id, - } - - _request(url, params=params) - - -# Getting software download links and filenames via Legacy API, -# which required SID username and password for Basic Authentication. -# Usually we should use `fetch_download_files` instead. -def fetch_download_files_via_legacy_api(username, password, display_id): - params = { - 'action': 'fetchFile', - 'sub_action': 'download_xml', - 'display_id': display_id, - } - - res = _request(C.URL_LEGACY_MP_API, - params=params, - auth=HTTPBasicAuth(username, password)) - xml = unescape(res.text.replace('\ufeff', '')) - e = etree.fromstring(xml.encode('utf-8')) - files = e.xpath('./download/files/file') - url_filename_list = [(f.find('url').text, f.find('name').text) - for f in files] - - return url_filename_list - - -def _get_transaction_name(trans_id): - transaction = _get_transaction('trans_id', trans_id) - return transaction['trans_name'] - - -def get_transaction_id(name): - """ - Search transaction ID using transaction Name or Display ID. - - Args: - name: transaction name or display id. - """ - transactions = get_transactions() - transaction_name = [t for t in transactions if t['trans_name'] == name] - if not transaction_name: - # Repeat search using Display ID - transaction_display_id = [t for t in transactions if t['trans_display_id'] == name] - if not transaction_display_id: - raise KeyError(f'Name or Display ID {name} not found in transactionsX') - else: - return transaction_display_id[0]['trans_id'] - else: - return transaction_name[0]['trans_id'] - - -def _get_transaction(key, value): - transactions = get_transactions() - trans = [t for t in transactions if t[key] == value] - if not trans: - raise KeyError(f'{key}: {value} not found in transactions') - return trans[0] - - -def _mp_request(**kwargs): - params = { - '_': int(time.time() * 1000), - } - if 'params' in kwargs: - params.update(kwargs['params']) - kwargs.pop('params') - - if params.get('action') != 'getInitialData': - kwargs['headers'] = {'xsrf-token': _xsrf_token()} - - kwargs['allow_redirects'] = False - - res = _request(C.URL_USERAPP_MP_SERVICE, params=params, **kwargs) - if (res.status_code == 302 - and res.headers.get('location').startswith(C.URL_ACCOUNT)): - if not _is_sso_session_active(): - raise Exception('Not logged in or session expired.' - ' Please login with `sap_sso_login`') - auth_userapps() - res = _request(C.URL_USERAPP_MP_SERVICE, params=params, **kwargs) - - return res - - -def _build_mnp_xml(**params): - namespace = 'http://xml.sap.com/2012/01/mnp' - mnp = f'{{{namespace}}}' - - request_keys = ['action', 'trans_name', 'sub_action', 'navigation'] - request_attrs = {k: params.get(k, '') for k in request_keys} - - entity_keys = ['call_for', 'sessionid'] - entity_attrs = {k: params.get(k, '') for k in entity_keys} - - request = etree.Element(f'{mnp}request', - nsmap={"mnp": namespace}, - attrib=request_attrs) - entity = etree.SubElement(request, f'{mnp}entity', attrib=entity_attrs) - entity.text = '' - - if 'entities' in params and type(params['entities']) is etree._Element: - entity.append(params['entities']) - - xml_str = etree.tostring(request, pretty_print=True) - return xml_str - - -def _xsrf_token(): - global _MP_XSRF_TOKEN - if _MP_XSRF_TOKEN: - return _MP_XSRF_TOKEN - - res = _mp_request(params={'action': 'getInitialData'}) - - _MP_XSRF_TOKEN = res.headers.get('xsrf-token') - return _MP_XSRF_TOKEN - - -def _clear_mp_cookies(startswith): - for domain in https_session.cookies.list_domains(): - if domain.startswith(startswith): - https_session.cookies.clear(domain=domain) - - -def _is_sso_session_active(): - try: - # Account information - _request(C.URL_ACCOUNT_ATTRIBUTES).json() - except Exception as e: - return False - - return True diff --git a/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py b/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py deleted file mode 100644 index 255e0e4..0000000 --- a/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py +++ /dev/null @@ -1,12 +0,0 @@ -from . import constants as C -from .sap_api_common import _request - - -def get_software_catalog(): - res = _request(C.URL_SOFTWARE_CENTER_VERSION).json() - revision = res['revision'] - - res = _request(C.URL_SOFTWARE_CATALOG.format(v=revision)).json() - catalog = res['SoftwareCatalog'] - - return catalog diff --git a/plugins/module_utils/software_center/__init__.py b/plugins/module_utils/software_center/__init__.py index 63d44d9..6a9cf69 100644 --- a/plugins/module_utils/software_center/__init__.py +++ b/plugins/module_utils/software_center/__init__.py @@ -1 +1 @@ -# This file makes the `software_center` directory a Python package. \ No newline at end of file +# This file makes the `software_center` directory into a Python package. \ No newline at end of file diff --git a/plugins/module_utils/software_center/download.py b/plugins/module_utils/software_center/download.py index d326b50..8962bcd 100644 --- a/plugins/module_utils/software_center/download.py +++ b/plugins/module_utils/software_center/download.py @@ -12,14 +12,22 @@ _HAS_DOWNLOAD_AUTHORIZATION = None -def validate_local_file_checksum(client, local_filepath, query=None, download_link=None, deduplicate=None): + +def validate_local_file_checksum(client, local_filepath, query=None, download_link=None, deduplicate=None, search_alternatives=False): # Validates a local file against the remote checksum from the server. - # Returns a dictionary with the validation status and a descriptive message. + # Returns a dictionary with the validation status and additional context. + result = { + 'validated': None, + 'message': '', + 'remote_filename': os.path.basename(local_filepath), + 'alternative_found': False + } try: if query: - # As we are validating an existing file, search_alternatives is forced to False. - file_details = search.find_file(client, query, deduplicate, search_alternatives=False) + file_details = search.find_file(client, query, deduplicate, search_alternatives=search_alternatives) download_link = file_details['download_link'] + result['remote_filename'] = file_details['filename'] + result['alternative_found'] = file_details['alternative_found'] download_link_final = _resolve_download_link(client, download_link) @@ -29,23 +37,27 @@ def validate_local_file_checksum(client, local_filepath, query=None, download_li headers = res.headers res.close() # We only need the headers, so close the connection. finally: - _clear_download_key_cookie(client) + clear_download_key_cookie(client) remote_etag = headers.get('ETag') if not remote_etag: - return {'validated': None, 'message': f"Checksum validation skipped: ETag header not found for URL '{download_link_final}'. Headers received: {headers}"} + result['message'] = f"Checksum validation skipped: ETag header not found for URL '{download_link_final}'. Headers received: {headers}" + return result if _is_checksum_matched(local_filepath, remote_etag): - return {'validated': True, 'message': 'Local file checksum is valid.'} + result['validated'] = True + result['message'] = 'Local file checksum is valid.' else: - return {'validated': False, 'message': 'Local file checksum is invalid.'} + result['validated'] = False + result['message'] = 'Local file checksum is invalid.' except exceptions.SapLaunchpadError as e: - return {'validated': None, 'message': f'Checksum validation skipped: {e}'} + result['message'] = f'Checksum validation skipped: {e}' + return result -def _check_similar_files(dest, filename): +def check_similar_files(dest, filename): # Checks for similar files in the download path based on the given filename. if os.path.splitext(filename)[1]: filename_base = os.path.splitext(filename)[0] @@ -90,7 +102,7 @@ def _check_download_authorization(client): ) -def _is_download_link_available(client, url, retry=0): +def is_download_link_available(client, url, retry=0): # Verifies if a download link is active and returns the final, resolved URL. # Returns None if the link is not available. # IMPORTANT: This function leaves download cookies in the session on success. @@ -119,7 +131,7 @@ def _resolve_download_link(client, url, retry=0): try: meta = {} while 'SAMLResponse' not in meta: - endpoint, meta = auth._get_sso_endpoint_meta(client, endpoint, data=meta) + endpoint, meta = auth.get_sso_endpoint_meta(client, endpoint, data=meta) # This POST will result in a redirect to the actual file URL. res = client.post(endpoint, data=meta, stream=True) @@ -138,7 +150,7 @@ def _resolve_download_link(client, url, retry=0): return endpoint -def _stream_file_to_disk(client, url, filepath, retry=0, **kwargs): +def stream_file_to_disk(client, url, filepath, retry=0, **kwargs): # Streams a large file to disk and verifies its checksum. kwargs.update({'stream': True}) try: @@ -152,10 +164,10 @@ def _stream_file_to_disk(client, url, filepath, retry=0, **kwargs): if retry >= C.MAX_RETRY_TIMES: raise exceptions.DownloadError(f"Connection failed after {C.MAX_RETRY_TIMES} retries: {e}") time.sleep(60 * (retry + 1)) - return _stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) + return stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) res.close() - _clear_download_key_cookie(client) + clear_download_key_cookie(client) checksum = res.headers.get('ETag', '').replace('"', '') if not checksum or _is_checksum_matched(filepath, checksum): @@ -166,11 +178,13 @@ def _stream_file_to_disk(client, url, filepath, retry=0, **kwargs): if retry >= C.MAX_RETRY_TIMES: raise exceptions.DownloadError(f'Failed to download {url}: checksum mismatch after {C.MAX_RETRY_TIMES} retries') - return _stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) + return stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) -def _clear_download_key_cookie(client): +def clear_download_key_cookie(client): # Clears download-specific cookies to prevent the cookie header from becoming too large. + # The software download server generates a cookie for every single file. + # If we don't clear it after download, the cookie header will become too long and the server will reject the request. for c in list(client.session.cookies): if c.domain == '.softwaredownloads.sap.com' and c.name != 'SESSIONID': client.session.cookies.clear(name=c.name, domain=c.domain, path='/') diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py index e479673..539b40b 100644 --- a/plugins/module_utils/software_center/main.py +++ b/plugins/module_utils/software_center/main.py @@ -58,7 +58,7 @@ def run_software_download(params): result['msg'] = f"File already exists: {filename}" return result - filename_similar_exists, filename_similar_names = download._check_similar_files(dest, filename) + filename_similar_exists, filename_similar_names = download.check_similar_files(dest, filename) if filename_similar_exists: result['skipped'] = True result['msg'] = f"Similar file(s) already exist: {', '.join(filename_similar_names)}" @@ -73,12 +73,27 @@ def run_software_download(params): # If checksum validation is requested, we perform the check here, # now that we have an authenticated session. if validate_checksum and os.path.exists(filepath): - validation_result = download.validate_local_file_checksum(client, filepath, query=query, download_link=download_link, deduplicate=deduplicate) - if validation_result['validated'] is True: + validation_result = download.validate_local_file_checksum( + client, + filepath, + query=query, + download_link=download_link, + deduplicate=deduplicate, + search_alternatives=search_alternatives + ) + + is_valid = validation_result['validated'] + # If an alternative file was used for the check, the local file is by definition outdated, + # even if the checksums happen to match (e.g., user renamed the file). + # We should force a re-download of the correct alternative file. + if validation_result['alternative_found']: + is_valid = False + + if is_valid is True: result['skipped'] = True result['msg'] = f"File already exists and checksum is valid: {filename}" return result - elif validation_result['validated'] is False: + elif is_valid is False: # The existing file is invalid, remove it to allow for re-download. # The final message will explain why the re-download occurred. os.remove(filepath) @@ -118,7 +133,7 @@ def run_software_download(params): result['msg'] = f"File with correct/alternative name already exists: {download_filename}" return result - final_url = download._is_download_link_available(client, download_link) + final_url = download.is_download_link_available(client, download_link) if final_url: if dry_run: msg = f"SAP Software is available to download: {download_filename}" @@ -128,7 +143,7 @@ def run_software_download(params): else: # The link is already resolved, just download it. filepath = os.path.join(dest, download_filename) - download._stream_file_to_disk(client, final_url, filepath) + download.stream_file_to_disk(client, final_url, filepath) result['changed'] = True if validation_result and validation_result.get('validated') is False: @@ -148,6 +163,6 @@ def run_software_download(params): result['failed'] = True result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" finally: - download._clear_download_key_cookie(client) + download.clear_download_key_cookie(client) return result \ No newline at end of file diff --git a/plugins/module_utils/software_center/search.py b/plugins/module_utils/software_center/search.py index f560512..535e8ac 100644 --- a/plugins/module_utils/software_center/search.py +++ b/plugins/module_utils/software_center/search.py @@ -133,19 +133,28 @@ def _search_software_fuzzy(client, query): def _filter_fuzzy_search(fuzzy_results, filename): # Filters fuzzy search output using the original filename. - suggested_filename = _prepare_search_filename_specific(filename) - fuzzy_results_filtered = [ - file for file in fuzzy_results - if file.get('Title', '').startswith(suggested_filename) - ] - - if len(fuzzy_results_filtered) == 0: - suggested_filename = _prepare_search_filename_nonspecific(filename) + if '*' in filename: + prefix, suffix = filename.split('*') + suffix_base = os.path.splitext(suffix)[0] + fuzzy_results_filtered = [ + file for file in fuzzy_results + if file.get('Title', '').startswith(prefix) and os.path.splitext(file.get('Title', ''))[0].endswith(suffix_base) + ] + suggested_filename = prefix + else: + suggested_filename = _prepare_search_filename_specific(filename) fuzzy_results_filtered = [ file for file in fuzzy_results if file.get('Title', '').startswith(suggested_filename) ] + if len(fuzzy_results_filtered) == 0: + suggested_filename = _prepare_search_filename_nonspecific(filename) + fuzzy_results_filtered = [ + file for file in fuzzy_results + if file.get('Title', '').startswith(suggested_filename) + ] + fuzzy_results_sorted = _sort_fuzzy_results(fuzzy_results_filtered, filename) return fuzzy_results_sorted, suggested_filename @@ -199,7 +208,7 @@ def _prepare_search_filename_specific(filename): elif filename_base.startswith('SAPHANACOCKPIT'): return filename_base.split('-')[0].rsplit('_', 1)[0] else: - return filename + return filename_name def _prepare_search_filename_nonspecific(filename): @@ -291,6 +300,7 @@ def _get_next_page_query(desc): def _get_valid_filename(software_found): # Ensures that CD Media have correct filenames from description. + # The API sometimes returns a numeric ID as the 'Title' for CD Media, while the actual filename is in the 'Description'. # Example: S4CORE105_INST_EXPORT_1.zip downloads as 19118000000000004323 if re.match(r'^\d+$', software_found['Title']): if software_found['Description'] and ' ' not in software_found['Description']: diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index f15d477..a2bd3f4 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -1,16 +1,17 @@ -# -*- coding: utf-8 -*- - -# SAP Maintenance Planner files retrieval +#!/usr/bin/python from __future__ import absolute_import, division, print_function -__metaclass__ = type - DOCUMENTATION = r''' --- module: maintenance_planner_files -short_description: SAP Maintenance Planner files retrieval +short_description: Retrieves a list of files from an SAP Maintenance Planner transaction. + +description: + - This module connects to the SAP Maintenance Planner to retrieve a list of all downloadable files associated with a specific transaction. + - It returns a list containing direct download links and filenames for each file. + - This is useful for automating the download of a complete stack file set defined in a Maintenance Planner transaction. version_added: 1.0.0 @@ -36,40 +37,42 @@ ''' EXAMPLES = r''' -- name: Execute Ansible Module 'maintenance_planner_files' to get files from MP - community.sap_launchpad.sap_launchpad_software_center_download: +- name: Retrieve a list of downloadable files from a Maintenance Planner transaction + community.sap_launchpad.maintenance_planner_files: suser_id: 'SXXXXXXXX' suser_password: 'password' transaction_name: 'MP_NEW_INST_20211015_044854' register: sap_mp_register - name: Display the list of download links and filenames - debug: - msg: - - "{{ sap_mp_register.download_basket }}" + ansible.builtin.debug: + msg: "Files found for transaction: {{ sap_mp_register.download_basket }}" ''' RETURN = r''' msg: - description: the status of the process + description: A message indicating the status of the operation. returned: always type: str + sample: "Successfully retrieved file list from SAP Maintenance Planner." download_basket: - description: a json list of software download links and filenames from the MP transaction + description: A list of files retrieved from the Maintenance Planner transaction. returned: always - type: json list + type: list + elements: dict + contains: + DirectLink: + description: The direct URL to download the file. + type: str + sample: "https://softwaredownloads.sap.com/file/0020000001234562023" + Filename: + description: The name of the file. + type: str + sample: "SAPCAR_1324-80000936.EXE" ''' - -######################### - import requests from ansible.module_utils.basic import AnsibleModule - -# Import runner -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 +from ..module_utils.maintenance_planner import main as maintenance_planner_runner def run_module(): @@ -84,9 +87,7 @@ def run_module(): # Define result dictionary objects to be passed back to Ansible result = dict( - download_basket={}, changed=False, - msg='' ) # Instantiate module @@ -97,59 +98,15 @@ def run_module(): # Check mode if module.check_mode: - module.exit_json(**result) + module.exit_json(changed=False, download_basket={}) - # Define variables based on module inputs - username = module.params.get('suser_id') - password = module.params.get('suser_password') - transaction_name = module.params.get('transaction_name') - validate_url = module.params.get('validate_url') - - # Main run - - try: - # EXEC: Retrieve login session, using Py Function from imported module in directory module_utils - session = sap_sso_login(username, password) - - # EXEC: Authenticate against userapps.support.sap.com - auth_userapps() - - # EXEC: Get MP stack transaction id from transaction name - transaction_id = get_transaction_id(transaction_name) - - # EXEC: Get a json list of download_links and download_filenames - download_basket_details = get_transaction_filename_url(transaction_id) - - if validate_url: - for pair in download_basket_details: - url = pair[0] - if not is_download_link_available(url): - module.fail_json(failed=True, msg='Download link is not available: {}'.format(url)) - - # Process return dictionary for Ansible - result['download_basket'] = [{'DirectLink': i[0], 'Filename': i[1]} for i in download_basket_details] - result['changed'] = True - result['msg'] = "Successful SAP maintenance planner stack generation" - - except ValueError as e: - # module.fail_json(msg='Stack files not found - ' + str(e), **result) - result['msg'] = "Stack files not found - " + str(e) - result['failed'] = True - except KeyError as e: - # module.fail_json(msg='Maintenance planner session not found - ' + str(e), **result) - result['msg'] = "Maintenance planner session not found - " + str(e) - result['failed'] = True - except requests.exceptions.HTTPError as e: - # module.fail_json(msg='SAP SSO authentication failed' + str(e), **result) - result['msg'] = "SAP SSO authentication failed - " + str(e) - result['failed'] = True - except Exception as e: - # module.fail_json(msg='An exception has occurred' + str(e), **result) - result['msg'] = "An exception has occurred - " + str(e) - result['failed'] = True - - # Return to Ansible - module.exit_json(**result) + result = maintenance_planner_runner.run_files(module.params) + + # The runner function indicates failure via a key in the result. + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) def main(): diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index 6e958eb..4473078 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -1,16 +1,17 @@ -# -*- coding: utf-8 -*- - -# SAP Maintenance Planner Stack XML download +#!/usr/bin/python from __future__ import absolute_import, division, print_function -__metaclass__ = type - DOCUMENTATION = r''' --- module: maintenance_planner_stack_xml_download -short_description: SAP Maintenance Planner Stack XML download +short_description: Downloads the stack.xml file from an SAP Maintenance Planner transaction. + +description: + - This module connects to the SAP Maintenance Planner to download the stack.xml file associated with a specific transaction. + - The stack.xml file contains the plan for a system update or installation and is used by tools like Software Update Manager (SUM). + - The file is saved to the specified destination directory. version_added: 1.0.0 @@ -32,7 +33,7 @@ type: str dest: description: - - Destination folder path. + - The path to an existing destination directory where the stack.xml file will be saved. required: true type: str author: @@ -41,35 +42,29 @@ ''' EXAMPLES = r''' -- name: Execute Ansible Module 'maintenance_planner_stack_xml_download' to get files from MP - community.sap_launchpad.sap_launchpad_software_center_download: +- name: Download a Stack XML file from a Maintenance Planner transaction + community.sap_launchpad.maintenance_planner_stack_xml_download: suser_id: 'SXXXXXXXX' suser_password: 'password' transaction_name: 'MP_NEW_INST_20211015_044854' dest: "/tmp/" - register: sap_mp_register -- name: Display the list of download links and filenames - debug: - msg: - - "{{ sap_mp_register.download_basket }}" + register: sap_mp_stack_xml_result +- name: Display the result message + ansible.builtin.debug: + msg: "{{ sap_mp_stack_xml_result.msg }}" ''' RETURN = r''' msg: - description: the status of the process + description: A message indicating the status of the download operation. returned: always type: str + sample: "SAP Maintenance Planner Stack XML successfully downloaded to /tmp/MP_STACK_20211015_044854.xml" ''' - -######################### - import requests from ansible.module_utils.basic import AnsibleModule - -# Import runner -from ..module_utils.sap_launchpad_maintenance_planner_runner import * -from ..module_utils.sap_id_sso import sap_sso_login +from ..module_utils.maintenance_planner import main as maintenance_planner_runner def run_module(): @@ -98,47 +93,13 @@ def run_module(): if module.check_mode: module.exit_json(**result) - # Define variables based on module inputs - username = module.params.get('suser_id') - password = module.params.get('suser_password') - transaction_name = module.params.get('transaction_name') - dest = module.params.get('dest') - - # Main run - - try: + result = maintenance_planner_runner.run_stack_xml_download(module.params) - # EXEC: Retrieve login session, using Py Function from imported module in directory module_utils - session = sap_sso_login(username, password) - - # EXEC: Authenticate against userapps.support.sap.com - auth_userapps() - - # EXEC: Get MP stack transaction id from transaction name - transaction_id = get_transaction_id(transaction_name) - - # EXEC: Download the MP Stack XML file - get_transaction_stack_xml(transaction_id, dest) - - # Process return dictionary for Ansible - result['changed'] = True - result['msg'] = "SAP Maintenance Planner Stack XML download successful" - - except KeyError as e: - # module.fail_json(msg='Maintenance planner session not found - ' + str(e), **result) - result['msg'] = "Maintenance planner session not found - " + str(e) - result['failed'] = True - except requests.exceptions.HTTPError as e: - # module.fail_json(msg='SAP SSO authentication failed' + str(e), **result) - result['msg'] = "SAP SSO authentication failed - " + str(e) - result['failed'] = True - except Exception as e: - # module.fail_json(msg='An exception has occurred' + str(e), **result) - result['msg'] = "An exception has occurred - " + str(e) - result['failed'] = True - - # Return to Ansible - module.exit_json(**result) + # The runner function indicates failure via a key in the result. + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) def main(): diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index 80257d5..c0bb800 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -6,7 +6,14 @@ --- module: software_center_download -short_description: SAP software download +short_description: Downloads software from the SAP Software Center. + +description: + - This module automates downloading files from the SAP Software Center. + - It can find a file using a search query or download it directly using a specific download link and filename. + - If a file is not found via search, it can look for alternative versions. + - It supports checksum validation to ensure file integrity and avoid re-downloading valid files. + - The module can also perform a dry run to check for file availability without downloading. version_added: 1.0.0 @@ -51,7 +58,8 @@ type: str deduplicate: description: - - How to handle multiple search results. + - "Specifies how to handle multiple search results for the same filename. Choices are `first` (oldest) or `last` (newest)." + choices: [ 'first', 'last' ] required: false type: str search_alternatives: @@ -88,27 +96,43 @@ download_link: 'https://softwaredownloads.sap.com/file/0010000000048502015' download_filename: 'IW_FNDGC100.SAR' dest: "/tmp/" +- name: Download a file, searching for alternatives and validating checksum + community.sap_launchpad.software_center_download: + suser_id: 'SXXXXXXXX' + suser_password: 'password' + search_query: 'IMDB_SERVER20_023_0-80002031.SAR' + dest: "/sap_media" + search_alternatives: true + deduplicate: "last" + validate_checksum: true ''' RETURN = r''' msg: - description: the status of the process + description: A message indicating the status of the download operation. returned: always type: str + sample: "Successfully downloaded SAP software: SAPCAR_1324-80000936.EXE" filename: - description: the name of the original or alternative file found to download. - returned: always + description: The name of the file that was downloaded or checked. This may be an alternative if one was found. + returned: on success or failure after finding a file type: str + sample: "SAPCAR_1324-80000936.EXE" alternative: - description: true if alternative file was found + description: A boolean indicating if an alternative file was downloaded instead of the one from the original search query. + returned: on success + type: bool +changed: + description: A boolean indicating if a file was downloaded or changed on the remote host. + returned: always + type: bool +skipped: + description: A boolean indicating if the download was skipped (e.g., file already exists and checksum is valid). returned: always type: bool ''' - from ansible.module_utils.basic import AnsibleModule - -# Import the main runner function from the module_utils from ..module_utils.software_center import main as software_center_runner @@ -124,7 +148,7 @@ def run_module(): download_filename=dict(type='str', required=False, default=''), dest=dict(type='str', required=True), dry_run=dict(type='bool', required=False, default=False), - deduplicate=dict(type='str', required=False, default=''), + deduplicate=dict(type='str', required=False, default='', choices=['first', 'last', '']), search_alternatives=dict(type='bool', required=False, default=False), validate_checksum=dict(type='bool', required=False, default=False) ) From 88e8ab5135046d835dedf120ad1fc01dd0108d64 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 9 Sep 2025 11:41:27 +0200 Subject: [PATCH 5/9] update download role with new validate_checksum for download module --- roles/sap_software_download/README.md | 7 +++++++ roles/sap_software_download/defaults/main.yml | 4 ++++ roles/sap_software_download/meta/argument_spec.yml | 8 ++++++++ roles/sap_software_download/tasks/download_files.yml | 2 ++ roles/sap_software_download/tasks/download_plan.yml | 2 ++ 5 files changed, 23 insertions(+) diff --git a/roles/sap_software_download/README.md b/roles/sap_software_download/README.md index e26dbe0..f41b73b 100644 --- a/roles/sap_software_download/README.md +++ b/roles/sap_software_download/README.md @@ -325,4 +325,11 @@ Specifies how to handle duplicate file results when using `sap_software_download If multiple files with the same name are found, this setting determines which one to download.
- `first`: Download the first file found
- `last`: Download the last file found.
+ +### sap_software_download_validate_checksum +- _Type:_ `bool`
+- _Default:_ `false`
+ +Enables checksum validation of existing files present in `sap_software_download_directory`.
+This does not affect automatic checksum validation of downloaded files.
diff --git a/roles/sap_software_download/defaults/main.yml b/roles/sap_software_download/defaults/main.yml index d4a209f..f7d553a 100644 --- a/roles/sap_software_download/defaults/main.yml +++ b/roles/sap_software_download/defaults/main.yml @@ -88,3 +88,7 @@ sap_software_download_ignore_validate_credentials: false # `first`: Download the first file found. # `last`: Download the last file found. # sap_software_download_deduplicate: first + +# Enables checksum validation of existing files present in `sap_software_download_directory`. +# This does not affect automatic checksum validation of downloaded files. +# sap_software_download_validate_checksum: true diff --git a/roles/sap_software_download/meta/argument_spec.yml b/roles/sap_software_download/meta/argument_spec.yml index f62f9da..83c7748 100644 --- a/roles/sap_software_download/meta/argument_spec.yml +++ b/roles/sap_software_download/meta/argument_spec.yml @@ -164,3 +164,11 @@ argument_specs: - If multiple files with the same name are found, this setting determines which one to download. - Value `first` - Download the first file found. - Value `last` - Download the last file found. + + sap_software_download_validate_checksum: + type: bool + required: false + default: false + description: + - Enables checksum validation of existing files present in `sap_software_download_directory`. + - This does not affect automatic checksum validation of downloaded files. diff --git a/roles/sap_software_download/tasks/download_files.yml b/roles/sap_software_download/tasks/download_files.yml index 1712f3b..391936f 100644 --- a/roles/sap_software_download/tasks/download_files.yml +++ b/roles/sap_software_download/tasks/download_files.yml @@ -9,6 +9,7 @@ dest: "{{ sap_software_download_directory }}" search_alternatives: "{{ sap_software_download_find_alternatives | d(true) }}" deduplicate: "{{ sap_software_download_deduplicate | d('') }}" + validate_checksum: "{{ sap_software_download_validate_checksum | d(false) }}" # Loop condition acts as when conditional loop: "{{ sap_software_download_files if sap_software_download_use_venv | d(true) else [] }}" loop_control: @@ -31,6 +32,7 @@ dest: "{{ sap_software_download_directory }}" search_alternatives: "{{ sap_software_download_find_alternatives | d(true) }}" deduplicate: "{{ sap_software_download_deduplicate | d('') }}" + validate_checksum: "{{ sap_software_download_validate_checksum | d(false) }}" # Loop condition acts as when conditional loop: "{{ sap_software_download_files if not sap_software_download_use_venv | d(true) else [] }}" loop_control: diff --git a/roles/sap_software_download/tasks/download_plan.yml b/roles/sap_software_download/tasks/download_plan.yml index b92feba..4e15633 100644 --- a/roles/sap_software_download/tasks/download_plan.yml +++ b/roles/sap_software_download/tasks/download_plan.yml @@ -8,6 +8,7 @@ download_link: "{{ item.DirectLink }}" download_filename: "{{ item.Filename }}" dest: "{{ sap_software_download_directory }}" + validate_checksum: "{{ sap_software_download_validate_checksum | d(false) }}" # Loop condition acts as when conditional loop: "{{ __sap_software_download_mp_transaction_results.download_basket if sap_software_download_use_venv | d(true) else [] }}" loop_control: @@ -29,6 +30,7 @@ download_link: "{{ item.DirectLink }}" download_filename: "{{ item.Filename }}" dest: "{{ sap_software_download_directory }}" + validate_checksum: "{{ sap_software_download_validate_checksum | d(false) }}" # Loop condition acts as when conditional loop: "{{ __sap_software_download_mp_transaction_results.download_basket if not sap_software_download_use_venv | d(true) else [] }}" loop_control: From 125f783a4b2bd6e392f0588879485104f110411e Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 9 Sep 2025 11:44:57 +0200 Subject: [PATCH 6/9] remove invalid execution flag on LICENSE file --- LICENSE | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 LICENSE diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 From 4b9d6258a9f8969b7c5ef855796ac6005028b95d Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Wed, 10 Sep 2025 15:09:27 +0200 Subject: [PATCH 7/9] update readme examples with new checksum variable --- roles/sap_software_download/README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/roles/sap_software_download/README.md b/roles/sap_software_download/README.md index f41b73b..fbea1d4 100644 --- a/roles/sap_software_download/README.md +++ b/roles/sap_software_download/README.md @@ -35,8 +35,6 @@ The target node must meet the following requirements: * For example, on some systems, these packages might be named `python3` and `python3-pip`. - - ## Execution @@ -63,7 +61,7 @@ The target node must meet the following requirements: ### Example -Download of SAP Software files using input list +Download of SAP Software files using input list. ```yaml --- - name: Ansible Play for downloading SAP Software @@ -77,12 +75,13 @@ Download of SAP Software files using input list sap_software_download_suser_id: "Enter SAP S-User ID" sap_software_download_suser_password: "Enter SAP S-User Password" sap_software_download_directory: "/software" + sap_software_download_validate_checksum: true sap_software_download_files: - 'SAPCAR_1115-70006178.EXE' - 'SAPEXE_100-80005509.SAR' ``` -Download of SAP Software files using Maintenance Plan +Download of SAP Software files using Maintenance Plan. ```yaml --- - name: Ansible Play for downloading SAP Software @@ -96,15 +95,17 @@ Download of SAP Software files using Maintenance Plan sap_software_download_suser_id: "Enter SAP S-User ID" sap_software_download_suser_password: "Enter SAP S-User Password" sap_software_download_directory: "/software" - sap_software_download_mp_transaction: 'Transaction Name or Display ID from Maintenance Planner' + sap_software_download_validate_checksum: true + sap_software_download_mp_transaction: 'MY-TRANSACTION-NAME' ``` Combined download of SAP Software files and Maintenance Plan transaction together with settings: -- Use default Python instead of Python virtual environment -- No validation of S-User credentials -- No validation of relationships -- No warnings for unavailable files -- No warnings for unavailable Maintenance Plan transaction +- Use default Python instead of Python virtual environment. +- No validation of S-User credentials. +- No validation of relationships. +- No warnings for unavailable files. +- No warnings for unavailable Maintenance Plan transaction. +- Validate checksum of already existing files with same name. ```yaml - name: Ansible Play for downloading SAP Software hosts: localhost @@ -123,10 +124,11 @@ Combined download of SAP Software files and Maintenance Plan transaction togethe sap_software_download_ignore_plan_not_found: true sap_software_download_validate_relationships: false sap_software_download_deduplicate: first + sap_software_download_validate_checksum: true sap_software_download_files: - 'SAPCAR_1115-70006178.EXE' - 'SAPEXE_100-80005509.SAR' - sap_software_download_mp_transaction: 'Transaction Name or Display ID from Maintenance Planner' + sap_software_download_mp_transaction: 'MY-TRANSACTION-NAME' ``` Download of SAP Software files using Python version `3.13`. ```yaml @@ -150,6 +152,7 @@ Download of SAP Software files using Python version `3.13`. sap_software_download_suser_id: "Enter SAP S-User ID" sap_software_download_suser_password: "Enter SAP S-User Password" sap_software_download_directory: "/software" + sap_software_download_validate_checksum: true sap_software_download_files: - 'SAPCAR_1115-70006178.EXE' - 'SAPEXE_100-80005509.SAR' From e9a2222372f3ac49997f51fbb54c779e4beb694c Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Wed, 10 Sep 2025 16:45:54 +0200 Subject: [PATCH 8/9] update example for module maintenance_planner_files --- docs/module_maintenance_planner_files.md | 151 +++++++++++++---------- 1 file changed, 87 insertions(+), 64 deletions(-) diff --git a/docs/module_maintenance_planner_files.md b/docs/module_maintenance_planner_files.md index 6e1393e..c9234c8 100644 --- a/docs/module_maintenance_planner_files.md +++ b/docs/module_maintenance_planner_files.md @@ -14,8 +14,6 @@ This module requires the following Python modules to be installed on the target - beautifulsoup4 - lxml -Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) - ## Execution ### Execution Flow @@ -40,57 +38,77 @@ The module follows a clear logic flow to retrieve the file list from a Maintenan * The module returns the final list of files as the `download_basket`, with each item containing a `DirectLink` and a `Filename`. ### Example -Obtain list of SAP Software files +> **NOTE:** The Python versions in these examples vary by operating system. Always use the version that is compatible with your specific system or managed node.
+> To simplify this process, the Ansible Role `sap_launchpad.sap_software_download` will install the correct Python version and required modules for you.
+ +Obtain list of SAP Software files using existing System Python. ```yaml -- name: Obtain list of SAP Software files - community.sap_launchpad.maintenance_planner_files: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - transaction_name: "Transaction Name or Display ID from Maintenance Planner" - register: __module_results +- name: Example play for Ansible Module maintenance_planner_files + hosts: all + tasks: + - name: Obtain list of SAP Software files + community.sap_launchpad.maintenance_planner_files: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + register: __module_results ``` -Obtain list of SAP Software files using Python Virtual Environment `/tmp/python_venv` +Obtain list of SAP Software files using existing Python Virtual Environment `/tmp/python_venv`. ```yaml -- name: Obtain list of SAP Software files using Python Virtual Environment - community.sap_launchpad.maintenance_planner_files: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - transaction_name: "Transaction Name or Display ID from Maintenance Planner" - register: __module_results - environment: - PATH: "/tmp/python_venv:{{ ansible_env.PATH }}" - PYTHONPATH: "/tmp/python_venv/lib/python3.11/site-packages" - VIRTUAL_ENV: "/tmp/python_venv" - vars: - ansible_python_interpreter: "/tmp/python_venv/bin/python3.11 }}" +- name: Example play for Ansible Module maintenance_planner_files + hosts: all + tasks: + - name: Obtain list of SAP Software files using Python Virtual Environment + community.sap_launchpad.maintenance_planner_files: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + register: __module_results + environment: + PATH: "/tmp/python_venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/python_venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/python_venv" + vars: + ansible_python_interpreter: "/tmp/python_venv/bin/python3.11 }}" ``` -### Output format -#### msg -- _Type:_ `string`
- -The status of execution. +Install prerequisites and obtain list of SAP Software files using existing System Python.
+**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. +```yaml +--- +- name: Example play for Ansible Module maintenance_planner_files + hosts: all + tasks: + - name: Install Python and Python package manager pip + ansible.builtin.package: + name: + - python311 + - python311-pip + state: present -#### download_basket -- _Type:_ `list` with elements of type `dictionary`
+ - name: Install Python module packages + ansible.builtin.package: + name: + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present -A Json list of software download links and filenames.
-```yml -- DirectLink: https://softwaredownloads.sap.com/file/0020000001739942021 - Filename: IMDB_SERVER20_060_0-80002031.SAR -- DirectLink: https://softwaredownloads.sap.com/file/0010000001440232021 - Filename: KD75379.SAR + - name: Obtain list of SAP Software files + community.sap_launchpad.maintenance_planner_files: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + register: __module_results ``` -## Further Information -### Installation of prerequisites -**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
- -Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +Install prerequisites and obtain list of SAP Software files using existing Python Virtual Environment `/tmp/python_venv`. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module maintenance_planner_files hosts: all tasks: - name: Install Python and Python package manager pip @@ -100,7 +118,7 @@ Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv - python311-pip state: present - - name: Pre-Steps - Install Python modules to Python venv + - name: Install Python modules to Python venv ansible.builtin.pip: name: - wheel @@ -110,32 +128,37 @@ Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv - lxml virtualenv: "/tmp/python_venv" virtualenv_command: "python3.11 -m venv" + + - name: Obtain list of SAP Software files using Python Virtual Environment + community.sap_launchpad.maintenance_planner_files: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + register: __module_results + environment: + PATH: "/tmp/python_venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/python_venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/python_venv" + vars: + ansible_python_interpreter: "/tmp/python_venv/bin/python3.11 }}" ``` -Prerequisite preparation using Python 3.11 system default
-```yaml ---- -- name: Example play to install prerequisites for sap_launchpad - hosts: all - tasks: - - name: Install Python and Python package manager pip - ansible.builtin.package: - name: - - python311 - - python311-pip - state: present +### Output format +#### msg +- _Type:_ `string`
- - name: Install Python module packages - ansible.builtin.package: - name: - - python311-wheel - - python311-urllib3 - - python311-requests - - python311-beautifulsoup4 - - python311-lxml - state: present +The status of execution. + +#### download_basket +- _Type:_ `list` with elements of type `dictionary`
+ +A Json list of software download links and filenames.
+```yml +- DirectLink: https://softwaredownloads.sap.com/file/0020000001739942021 + Filename: IMDB_SERVER20_060_0-80002031.SAR +- DirectLink: https://softwaredownloads.sap.com/file/0010000001440232021 + Filename: KD75379.SAR ``` -**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ## License Apache 2.0 From 9d4f7707e7fc88efad1d841cb77e26c74f4e0dd9 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Wed, 10 Sep 2025 17:26:01 +0200 Subject: [PATCH 9/9] update readme examples for remaining modules --- docs/DEVELOPER_NOTES.md | 57 +--- docs/module_license_keys.md | 286 +++++++++++------- docs/module_maintenance_planner_files.md | 2 + ..._maintenance_planner_stack_xml_download.md | 139 +++++---- docs/module_software_center_download.md | 191 +++++++----- docs/module_systems_info.md | 127 ++++---- 6 files changed, 458 insertions(+), 344 deletions(-) diff --git a/docs/DEVELOPER_NOTES.md b/docs/DEVELOPER_NOTES.md index 2949687..0bc59b5 100644 --- a/docs/DEVELOPER_NOTES.md +++ b/docs/DEVELOPER_NOTES.md @@ -51,6 +51,7 @@ It is recommended to install dependencies in venv that can be removed after exec ``` ### Installation of dependencies with Python system default +**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ```yaml - name: Example play to install prerequisites with Python system default hosts: all @@ -62,14 +63,15 @@ It is recommended to install dependencies in venv that can be removed after exec - python311-pip state: present - - name: Install Python modules to Python system default - ansible.builtin.pip: - name: - - wheel - - urllib3 - - requests - - beautifulsoup4 - - lxml + - name: Install Python module packages + ansible.builtin.package: + name: + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present ``` ## Additional execution methods @@ -110,42 +112,3 @@ ansible-playbook --timeout 60 ./sample-playbook.yml \ --connection 'ssh' --user "$target_user" --inventory "$target_host," --private-key "$target_private_key_file" \ --ssh-extra-args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ProxyCommand='ssh -W %h:%p $bastion_user@$bastion_host -p $bastion_port -i $bastion_private_key_file -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'" ``` - -## Execution of Python Modules directly -### Setup local Python environment -```shell -# Change directory to Python scripts source -cd ./plugins - -# Create isolated Python (protect system Python) -pyenv install 3.9.6 -pyenv virtualenv 3.9.6 sap_launchpad -pyenv activate sap_launchpad - -# Install Python Modules to current Python environment -pip3 install beautifulsoup4 lxml requests - -# Run Python, import Python Modules and run Python Functions -python3 -``` - -### Execute Python Functions -```python ->>> from module_utils.sap_id_sso import sap_sso_login ->>> from module_utils.sap_launchpad_software_center_download_runner import * ->>> ->>> # Debug ->>> # from module_utils.sap_api_common import debug_https ->>> # debug_https() ->>> ->>> ## Perform API login requests to SAP Support ->>> username='S0000000' ->>> password='password' ->>> sap_sso_login(username, password) ->>> ## Perform API activity requests to SAP Support (e.g. software search without deduplication, and download software) ->>> query_result = search_software_filename("HCMT_057_0-80003261.SAR",'') ->>> download_software(*query_result, output_dir='/tmp') -... ->>> ## API responses from SAP Support ->>> exit() -``` diff --git a/docs/module_license_keys.md b/docs/module_license_keys.md index c18dbd4..d1718c0 100644 --- a/docs/module_license_keys.md +++ b/docs/module_license_keys.md @@ -17,8 +17,6 @@ This module requires the following Python modules to be installed on the target - beautifulsoup4 - lxml -Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) - ## Execution ### Execution Flow @@ -59,97 +57,83 @@ The module follows a sophisticated logic flow to determine whether to create, up * If `download_path` is specified, the license file is also saved to that directory. ### Example +> **NOTE:** The Python versions in these examples vary by operating system. Always use the version that is compatible with your specific system or managed node.
+ +Create a new SAP system and create new license. ```yaml -- name: Create a new system and generate license keys - community.sap_launchpad.license_keys: - suser_id: 'SXXXXXXXX' - suser_password: 'password' - installation_nr: '12345678' - system: - # 'nr' is omitted to create a new system - product: "SAP S/4HANA" - version: "SAP S/4HANA 2022" - data: - sysid: "S4H" - sysname: "s4hana-new-dev" - systype: "Application Server (ABAP)" - sysdb: "SAP HANA" - sysos: "Linux on x86_64 64bit" - sys_depl: "Private - On Premise" - licenses: - - type: "SAP S/4HANA" - data: - hwkey: "A1234567890" - expdate: "99991231" - download_path: "/tmp/licenses" - register: result - -- name: Update an existing system and remove other licenses - community.sap_launchpad.license_keys: - suser_id: 'SXXXXXXXX' - suser_password: 'password' - installation_nr: '12345678' - system: - nr: '0000123456' # Specify the system number to update - product: "SAP S/4HANA" - version: "SAP S/4HANA 2022" - data: - sysid: "S4H" - sysname: "s4hana-new-dev" - systype: "Application Server (ABAP)" - sysdb: "SAP HANA" - sysos: "Linux on x86_64 64bit" - sys_depl: "Private - On Premise" - licenses: - - type: "SAP S/4HANA" - data: - hwkey: "A1234567890" - expdate: "99991231" - delete_other_licenses: true - register: result - -- name: Display the license file content - ansible.builtin.debug: - var: result.license_file +--- +- name: Example play for Ansible Module license_keys + hosts: all + tasks: + - name: Create a new system and generate license keys + community.sap_launchpad.license_keys: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + installation_nr: "Your installation number" + system: + # 'nr' is omitted to create a new system + product: "SAP S/4HANA" + version: "SAP S/4HANA 2022" + data: + sysid: "S4H" + sysname: "s4hana-new-dev" + systype: "Application Server (ABAP)" + sysdb: "SAP HANA" + sysos: "Linux on x86_64 64bit" + sys_depl: "Private - On Premise" + licenses: + - type: "SAP S/4HANA" + data: + hwkey: "Your hardware key" + expdate: "99991231" + download_path: "/tmp/licenses" + register: result + + - name: Display the license file content + ansible.builtin.debug: + var: result.license_file ``` -### Output format -#### license_file -- _Type:_ `string`
- -The license file content containing the digital signatures of the specified licenses. This is returned when licenses are successfully generated or updated. -**Sample:** -```text ------ 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 +Create a new license for existing SAP system. +```yaml +--- +- name: Example play for Ansible Module license_keys + hosts: all + tasks: + - name: Update an existing system and remove other licenses + community.sap_launchpad.license_keys: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + installation_nr: "Your installation number" + system: + nr: '0000123456' # Specify the system number to update + product: "SAP S/4HANA" + version: "SAP S/4HANA 2022" + data: + sysid: "S4H" + sysname: "s4hana-new-dev" + systype: "Application Server (ABAP)" + sysdb: "SAP HANA" + sysos: "Linux on x86_64 64bit" + sys_depl: "Private - On Premise" + licenses: + - type: "SAP S/4HANA" + data: + hwkey: "Your hardware key" + expdate: "99991231" + delete_other_licenses: true + register: result + + - name: Display the license file content + ansible.builtin.debug: + var: result.license_file ``` -## Further Information -### Installation of prerequisites -**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
- -Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +Install prerequisites and create new SAP system using existing System Python.
+**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module license_keys hosts: all tasks: - name: Install Python and Python package manager pip @@ -159,22 +143,49 @@ Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv - python311-pip state: present - - name: Pre-Steps - Install Python modules to Python venv - ansible.builtin.pip: + - name: Install Python module packages + ansible.builtin.package: name: - - wheel - - urllib3 - - requests - - beautifulsoup4 - - lxml - virtualenv: "/tmp/python_venv" - virtualenv_command: "python3.11 -m venv" + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present + + - name: Create a new system and generate license keys + community.sap_launchpad.license_keys: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + installation_nr: "Your installation number" + system: + # 'nr' is omitted to create a new system + product: "SAP S/4HANA" + version: "SAP S/4HANA 2022" + data: + sysid: "S4H" + sysname: "s4hana-new-dev" + systype: "Application Server (ABAP)" + sysdb: "SAP HANA" + sysos: "Linux on x86_64 64bit" + sys_depl: "Private - On Premise" + licenses: + - type: "SAP S/4HANA" + data: + hwkey: "Your hardware key" + expdate: "99991231" + download_path: "/tmp/licenses" + register: result + + - name: Display the license file content + ansible.builtin.debug: + var: result.license_file ``` -Prerequisite preparation using Python 3.11 system default
+Install prerequisites and create new SAP system using existing Python Virtual Environment `/tmp/python_venv`. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module license_keys hosts: all tasks: - name: Install Python and Python package manager pip @@ -184,17 +195,80 @@ Prerequisite preparation using Python 3.11 system default
- python311-pip state: present - - name: Install Python module packages - ansible.builtin.package: + - name: Install Python modules to Python venv + ansible.builtin.pip: name: - - python311-wheel - - python311-urllib3 - - python311-requests - - python311-beautifulsoup4 - - python311-lxml - state: present + - wheel + - urllib3 + - requests + - beautifulsoup4 + - lxml + virtualenv: "/tmp/python_venv" + virtualenv_command: "python3.11 -m venv" + + - name: Create a new system and generate license keys + community.sap_launchpad.license_keys: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + installation_nr: "Your installation number" + system: + # 'nr' is omitted to create a new system + product: "SAP S/4HANA" + version: "SAP S/4HANA 2022" + data: + sysid: "S4H" + sysname: "s4hana-new-dev" + systype: "Application Server (ABAP)" + sysdb: "SAP HANA" + sysos: "Linux on x86_64 64bit" + sys_depl: "Private - On Premise" + licenses: + - type: "SAP S/4HANA" + data: + hwkey: "Your hardware key" + expdate: "99991231" + download_path: "/tmp/licenses" + register: result + environment: + PATH: "/tmp/python_venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/python_venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/python_venv" + vars: + ansible_python_interpreter: "/tmp/python_venv/bin/python3.11 }}" + + - name: Display the license file content + ansible.builtin.debug: + var: result.license_file +``` + +### Output format +#### license_file +- _Type:_ `string`
+ +The license file content containing the digital signatures of the specified licenses. This is returned when licenses are successfully generated or updated. +**Sample:** +```text +----- 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 ``` -**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ## License Apache 2.0 diff --git a/docs/module_maintenance_planner_files.md b/docs/module_maintenance_planner_files.md index c9234c8..7f331e8 100644 --- a/docs/module_maintenance_planner_files.md +++ b/docs/module_maintenance_planner_files.md @@ -43,6 +43,7 @@ The module follows a clear logic flow to retrieve the file list from a Maintenan Obtain list of SAP Software files using existing System Python. ```yaml +--- - name: Example play for Ansible Module maintenance_planner_files hosts: all tasks: @@ -56,6 +57,7 @@ Obtain list of SAP Software files using existing System Python. Obtain list of SAP Software files using existing Python Virtual Environment `/tmp/python_venv`. ```yaml +--- - name: Example play for Ansible Module maintenance_planner_files hosts: all tasks: diff --git a/docs/module_maintenance_planner_stack_xml_download.md b/docs/module_maintenance_planner_stack_xml_download.md index f670150..9bab198 100644 --- a/docs/module_maintenance_planner_stack_xml_download.md +++ b/docs/module_maintenance_planner_stack_xml_download.md @@ -14,8 +14,6 @@ This module requires the following Python modules to be installed on the target - beautifulsoup4 - lxml -Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) - ## Execution ### Execution Flow @@ -41,48 +39,50 @@ The module follows a clear logic flow to download the stack XML file from a Main * The module returns a success message indicating the full path where the `stack.xml` file was saved. ### Example -Obtain Stack file +> **NOTE:** The Python versions in these examples vary by operating system. Always use the version that is compatible with your specific system or managed node.
+> To simplify this process, the Ansible Role `sap_launchpad.sap_software_download` will install the correct Python version and required modules for you.
+ +Obtain Stack file using existing System Python. ```yaml -- name: Obtain Stack file - community.sap_launchpad.maintenance_planner_stack_xml_download: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - transaction_name: "Transaction Name or Display ID from Maintenance Planner" - dest: "/software" - register: __module_results +--- +- name: Example play for Ansible Module maintenance_planner_stack_xml_download + hosts: all + tasks: + - name: Obtain Stack file + community.sap_launchpad.maintenance_planner_stack_xml_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + dest: "Enter download path (e.g. /software)" + register: __module_results ``` -Obtain Stack file using Python Virtual Environment `/tmp/venv` +Obtain Stack file using existing Python Virtual Environment `/tmp/venv`. ```yaml -- name: Obtain Stack file using Python Virtual Environment - community.sap_launchpad.maintenance_planner_stack_xml_download: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - transaction_name: "Transaction Name or Display ID from Maintenance Planner" - dest: "/software" - register: __module_results - environment: - PATH: "/tmp/venv:{{ ansible_env.PATH }}" - PYTHONPATH: "/tmp/venv/lib/python3.11/site-packages" - VIRTUAL_ENV: "/tmp/venv" - vars: - ansible_python_interpreter: "/tmp/venv/bin/python3.11 }}" +--- +- name: Example play for Ansible Module maintenance_planner_stack_xml_download + hosts: all + tasks: + - name: Obtain Stack file using Python Virtual Environment + community.sap_launchpad.maintenance_planner_stack_xml_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + dest: "Enter download path (e.g. /software)" + register: __module_results + environment: + PATH: "/tmp/venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/venv" + vars: + ansible_python_interpreter: "/tmp/venv/bin/python3.11 }}" ``` -### Output format -#### msg -- _Type:_ `string`
- -The status of execution. - -## Further Information -### Installation of prerequisites -**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
- -Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +Install prerequisites and obtain Stack file using existing System Python.
+**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module maintenance_planner_stack_xml_download hosts: all tasks: - name: Install Python and Python package manager pip @@ -92,22 +92,29 @@ Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv - python311-pip state: present - - name: Pre-Steps - Install Python modules to Python venv - ansible.builtin.pip: + - name: Install Python module packages + ansible.builtin.package: name: - - wheel - - urllib3 - - requests - - beautifulsoup4 - - lxml - virtualenv: "/tmp/python_venv" - virtualenv_command: "python3.11 -m venv" + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present + + - name: Obtain Stack file + community.sap_launchpad.maintenance_planner_stack_xml_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + dest: "Enter download path (e.g. /software)" + register: __module_results ``` -Prerequisite preparation using Python 3.11 system default
+Install prerequisites and obtain Stack file using existing Python Virtual Environment `/tmp/python_venv`. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module maintenance_planner_stack_xml_download hosts: all tasks: - name: Install Python and Python package manager pip @@ -117,17 +124,37 @@ Prerequisite preparation using Python 3.11 system default
- python311-pip state: present - - name: Install Python module packages - ansible.builtin.package: + - name: Install Python modules to Python venv + ansible.builtin.pip: name: - - python311-wheel - - python311-urllib3 - - python311-requests - - python311-beautifulsoup4 - - python311-lxml - state: present + - wheel + - urllib3 + - requests + - beautifulsoup4 + - lxml + virtualenv: "/tmp/python_venv" + virtualenv_command: "python3.11 -m venv" + + - name: Obtain Stack file using Python Virtual Environment + community.sap_launchpad.maintenance_planner_stack_xml_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + transaction_name: "Transaction Name or Display ID from Maintenance Planner" + dest: "Enter download path (e.g. /software)" + register: __module_results + environment: + PATH: "/tmp/venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/venv" + vars: + ansible_python_interpreter: "/tmp/venv/bin/python3.11 }}" ``` -**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. + +### Output format +#### msg +- _Type:_ `string`
+ +The status of execution. ## License Apache 2.0 diff --git a/docs/module_software_center_download.md b/docs/module_software_center_download.md index a46a1bf..8351918 100644 --- a/docs/module_software_center_download.md +++ b/docs/module_software_center_download.md @@ -16,8 +16,6 @@ This module requires the following Python modules to be installed on the target - beautifulsoup4 - lxml -Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) - ## Execution ### Execution Flow @@ -58,85 +56,87 @@ The module follows a sophisticated logic flow to determine whether to download, * **After every download**, the module automatically validates the downloaded file's checksum against the one provided by the server. If they don't match, it will delete the corrupt file and retry the download. ### Example +> **NOTE:** The Python versions in these examples vary by operating system. Always use the version that is compatible with your specific system or managed node.
+> To simplify this process, the Ansible Role `sap_launchpad.sap_software_download` will install the correct Python version and required modules for you.
+ Download SAP Software file ```yaml -- name: Download SAP Software file using search_query - community.sap_launchpad.software_center_download: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - search_query: "Enter SAP Software file name" - dest: "Enter download path (e.g. /software)" +--- +- name: Example play for Ansible Module software_center_download + hosts: all + tasks: + - name: Download SAP Software file using search_query + community.sap_launchpad.software_center_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + search_query: "Enter SAP Software file name" + dest: "Enter download path (e.g. /software)" ``` Download SAP Software file using download_link and download_filename ```yaml -- name: Download SAP Software file using download_link and download_filename - community.sap_launchpad.software_center_download: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - download_link: 'https://softwaredownloads.sap.com/file/0010000000048502015' - download_filename: 'IW_FNDGC100.SAR' - dest: "Enter download path (e.g. /software)" +--- +- name: Example play for Ansible Module software_center_download + hosts: all + tasks: + - name: Download SAP Software file using download_link and download_filename + community.sap_launchpad.software_center_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + download_link: 'https://softwaredownloads.sap.com/file/0010000000048502015' + download_filename: 'IW_FNDGC100.SAR' + dest: "Enter download path (e.g. /software)" ``` Download list of SAP Software files, but search for alternatives if not found ```yaml -- name: Download list of SAP Software files - community.sap_launchpad.software_center_download: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - search_query: "{{ item }}" - dest: "Enter download path (e.g. /software)" - search_alternatives: true - deduplicate: "last" - loop: - - "Enter SAP Software file name 1" - - "Enter SAP Software file name 2" - loop_control: - label: "{{ item }} : {{ __module_results.msg | d('') }}" - register: __module_results - retries: 1 - until: __module_results is not failed +--- +- name: Example play for Ansible Module software_center_download + hosts: all + tasks: + - name: Download list of SAP Software files + community.sap_launchpad.software_center_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + search_query: "{{ item }}" + dest: "Enter download path (e.g. /software)" + search_alternatives: true + deduplicate: "last" + loop: + - "Enter SAP Software file name 1" + - "Enter SAP Software file name 2" + loop_control: + label: "{{ item }} : {{ __module_results.msg | d('') }}" + register: __module_results + retries: 1 + until: __module_results is not failed ``` Download SAP Software file using Python Virtual Environment `/tmp/venv` ```yaml -- name: Download list of SAP Software files - community.sap_launchpad.software_center_download: - suser_id: "Enter SAP S-User ID" - suser_password: "Enter SAP S-User Password" - search_query: "{{ item }}" - dest: "Enter download path (e.g. /software)" - loop: - - "Enter SAP Software file name 1" - - "Enter SAP Software file name 2" - loop_control: - label: "{{ item }} : {{ __module_results.msg | d('') }}" - register: __module_results - retries: 1 - until: __module_results is not failed - environment: - PATH: "/tmp/venv:{{ ansible_env.PATH }}" - PYTHONPATH: "/tmp/venv/lib/python3.11/site-packages" - VIRTUAL_ENV: "/tmp/venv" - vars: - ansible_python_interpreter: "/tmp/venv/bin/python3.11 }}" +--- +- name: Example play for Ansible Module software_center_download + hosts: all + tasks: + - name: Download SAP Software file using search_query + community.sap_launchpad.software_center_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + search_query: "Enter SAP Software file name" + dest: "Enter download path (e.g. /software)" + environment: + PATH: "/tmp/venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/venv" + vars: + ansible_python_interpreter: "/tmp/venv/bin/python3.11 }}" ``` -### Output format -#### msg -- _Type:_ `string`
- -A message indicating the status of the download operation. - -## Further Information -### Installation of prerequisites -**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
- -Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +Install prerequisites and download SAP Software file using existing System Python.
+**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module software_center_download hosts: all tasks: - name: Install Python and Python package manager pip @@ -146,22 +146,28 @@ Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv - python311-pip state: present - - name: Pre-Steps - Install Python modules to Python venv - ansible.builtin.pip: + - name: Install Python module packages + ansible.builtin.package: name: - - wheel - - urllib3 - - requests - - beautifulsoup4 - - lxml - virtualenv: "/tmp/python_venv" - virtualenv_command: "python3.11 -m venv" + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present + + - name: Download SAP Software file using search_query + community.sap_launchpad.software_center_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + search_query: "Enter SAP Software file name" + dest: "Enter download path (e.g. /software)" ``` -Prerequisite preparation using Python 3.11 system default
+Install prerequisites and download SAP Software file using existing Python Virtual Environment `/tmp/python_venv`. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module software_center_download hosts: all tasks: - name: Install Python and Python package manager pip @@ -171,17 +177,36 @@ Prerequisite preparation using Python 3.11 system default
- python311-pip state: present - - name: Install Python module packages - ansible.builtin.package: + - name: Install Python modules to Python venv + ansible.builtin.pip: name: - - python311-wheel - - python311-urllib3 - - python311-requests - - python311-beautifulsoup4 - - python311-lxml - state: present + - wheel + - urllib3 + - requests + - beautifulsoup4 + - lxml + virtualenv: "/tmp/python_venv" + virtualenv_command: "python3.11 -m venv" + + - name: Download SAP Software file using search_query + community.sap_launchpad.software_center_download: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + search_query: "Enter SAP Software file name" + dest: "Enter download path (e.g. /software)" + environment: + PATH: "/tmp/python_venv:{{ ansible_env.PATH }}" + PYTHONPATH: "/tmp/python_venv/lib/python3.11/site-packages" + VIRTUAL_ENV: "/tmp/python_venv" + vars: + ansible_python_interpreter: "/tmp/python_venv/bin/python3.11 }}" ``` -**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. + +### Output format +#### msg +- _Type:_ `string`
+ +A message indicating the status of the download operation. ## License Apache 2.0 diff --git a/docs/module_systems_info.md b/docs/module_systems_info.md index 56be39a..5c0c8ce 100644 --- a/docs/module_systems_info.md +++ b/docs/module_systems_info.md @@ -14,8 +14,6 @@ This module requires the following Python modules to be installed on the target - beautifulsoup4 - lxml -Installation instructions are available at [Installation of prerequisites](#installation-of-prerequisites) - ## Execution ### Execution Flow @@ -33,41 +31,38 @@ The module follows a straightforward logic flow to retrieve system information. * Each system in the list is a dictionary containing its details. ### Example +> **NOTE:** The Python versions in these examples vary by operating system. Always use the version that is compatible with your specific system or managed node.
+ +Get SAP system details using various search filters. ```yaml -- name: Get all systems for a specific installation number - community.sap_launchpad.systems_info: - suser_id: 'SXXXXXXXX' - suser_password: 'password' - filter: "Insnr eq '1234567890'" - register: result - -- name: Display system details - ansible.builtin.debug: - var: result.systems - -- name: Get a specific system by SID and product description - 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: Example play for Ansible Module systems_info + hosts: all + tasks: + - name: Get all systems for a specific installation number + community.sap_launchpad.systems_info: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + filter: "Insnr eq '1234567890'" + register: __module_results + + - name: Display system details + ansible.builtin.debug: + var: __module_results.systems + + - name: Get a specific system by SID and product description + community.sap_launchpad.systems_info: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + filter: "Insnr eq '12345678' and sysid eq 'H01' and ProductDescr eq 'SAP S/4HANA'" + register: __module_results ``` -### Output format -#### systems -- _Type:_ `list` of `dictionaries`
- -A list of dictionaries, where each dictionary represents an SAP system.
-The product version ID may be returned under the 'Version' or 'Prodver' key, depending on the system's age and type. - -## Further Information -### Installation of prerequisites -**All preparation steps are included in role `sap_launchpad.sap_software_download`.**
- -Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended) +Install prerequisites and get SAP system details using existing System Python.
+**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module systems_info hosts: all tasks: - name: Install Python and Python package manager pip @@ -77,22 +72,32 @@ Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv - python311-pip state: present - - name: Pre-Steps - Install Python modules to Python venv - ansible.builtin.pip: + - name: Install Python module packages + ansible.builtin.package: name: - - wheel - - urllib3 - - requests - - beautifulsoup4 - - lxml - virtualenv: "/tmp/python_venv" - virtualenv_command: "python3.11 -m venv" + - python311-wheel + - python311-urllib3 + - python311-requests + - python311-beautifulsoup4 + - python311-lxml + state: present + + - name: Get all systems for a specific installation number + community.sap_launchpad.systems_info: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + filter: "Insnr eq '1234567890'" + register: __module_results + + - name: Display system details + ansible.builtin.debug: + var: __module_results.systems ``` -Prerequisite preparation using Python 3.11 system default
+Install prerequisites and get SAP system details using existing Python Virtual Environment `/tmp/python_venv`. ```yaml --- -- name: Example play to install prerequisites for sap_launchpad +- name: Example play for Ansible Module systems_info hosts: all tasks: - name: Install Python and Python package manager pip @@ -102,17 +107,35 @@ Prerequisite preparation using Python 3.11 system default
- python311-pip state: present - - name: Install Python module packages - ansible.builtin.package: + - name: Install Python modules to Python venv + ansible.builtin.pip: name: - - python311-wheel - - python311-urllib3 - - python311-requests - - python311-beautifulsoup4 - - python311-lxml - state: present + - wheel + - urllib3 + - requests + - beautifulsoup4 + - lxml + virtualenv: "/tmp/python_venv" + virtualenv_command: "python3.11 -m venv" + + - name: Get all systems for a specific installation number + community.sap_launchpad.systems_info: + suser_id: "Enter SAP S-User ID" + suser_password: "Enter SAP S-User Password" + filter: "Insnr eq '1234567890'" + register: __module_results + + - name: Display system details + ansible.builtin.debug: + var: __module_results.systems ``` -**NOTE:** Python modules are installed as packages to avoid `externally-managed-environment` error. + +### Output format +#### systems +- _Type:_ `list` of `dictionaries`
+ +A list of dictionaries, where each dictionary represents an SAP system.
+The product version ID may be returned under the 'Version' or 'Prodver' key, depending on the system's age and type. ## License Apache 2.0