From 7891c242e92c65807f89a29aedb63ed6f971ff40 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Wed, 8 Oct 2025 11:59:05 +0200 Subject: [PATCH 1/3] update linting, add sanity workflow and fix sanity errors --- .ansible-lint | 14 +++- .github/workflows/.ansible-lint | 10 --- .../ansible-lint-sap_software_download.yml | 36 ++++++++ .github/workflows/ansible-lint.yml | 22 +++-- .github/workflows/ansible-test-sanity.yml | 84 +++++++++++++++++++ README.md | 2 +- docs/FAQ.md | 2 +- plugins/module_utils/auth.py | 73 +++++++++++++--- plugins/module_utils/client.py | 47 +++++++++-- plugins/module_utils/exceptions.py | 5 +- .../maintenance_planner/__init__.py | 1 - .../module_utils/maintenance_planner/api.py | 78 ++++++++++++++++- .../module_utils/maintenance_planner/main.py | 67 +++++++++------ .../module_utils/software_center/__init__.py | 1 - .../module_utils/software_center/download.py | 30 ++++++- plugins/module_utils/software_center/main.py | 25 +++++- .../module_utils/software_center/search.py | 9 +- plugins/module_utils/systems/__init__.py | 1 - plugins/module_utils/systems/api.py | 51 ++++++++++- plugins/module_utils/systems/main.py | 59 ++++++++++--- plugins/modules/license_keys.py | 20 +++-- plugins/modules/maintenance_planner_files.py | 15 +++- .../maintenance_planner_stack_xml_download.py | 11 ++- plugins/modules/software_center_download.py | 29 +++++-- plugins/modules/systems_info.py | 10 ++- tests/.gitkeep | 0 tests/sanity/ignore-2.14.txt | 5 ++ tests/sanity/ignore-2.15.txt | 5 ++ tests/sanity/ignore-2.16.txt | 5 ++ tests/sanity/ignore-2.17.txt | 5 ++ tests/sanity/ignore-2.18.txt | 5 ++ tests/sanity/ignore-2.19.txt | 5 ++ tests/sanity/ignore-2.20.txt | 5 ++ 33 files changed, 616 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/.ansible-lint create mode 100644 .github/workflows/ansible-lint-sap_software_download.yml create mode 100644 .github/workflows/ansible-test-sanity.yml delete mode 100644 tests/.gitkeep create mode 100644 tests/sanity/ignore-2.14.txt create mode 100644 tests/sanity/ignore-2.15.txt create mode 100644 tests/sanity/ignore-2.16.txt create mode 100644 tests/sanity/ignore-2.17.txt create mode 100644 tests/sanity/ignore-2.18.txt create mode 100644 tests/sanity/ignore-2.19.txt create mode 100644 tests/sanity/ignore-2.20.txt diff --git a/.ansible-lint b/.ansible-lint index ff93a8f..925cfb0 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -1,16 +1,24 @@ --- -# Collection wide lint-file -# DO NOT CHANGE +## Collection wide ansible-lint configuration file. +# Changes for ansible-lint v25.7.0+ +# - Always executed from collection root using collection configuration. +# - .ansible-lint-ignore can be used to ignore files, not folders. +## Execution examples: +# ansible-lint +# ansible-lint roles/sap_swpm +# ansible-lint roles/sap_install_media_detect -c roles/sap_install_media_detect/.ansible-lint + exclude_paths: - .ansible/ - .cache/ - .github/ - # - docs/ - changelogs/ - playbooks/ - tests/ + enable_list: - yaml + skip_list: # We don't want to enforce new Ansible versions for Galaxy: - meta-runtime[unsupported-version] diff --git a/.github/workflows/.ansible-lint b/.github/workflows/.ansible-lint deleted file mode 100644 index 69435ba..0000000 --- a/.github/workflows/.ansible-lint +++ /dev/null @@ -1,10 +0,0 @@ ---- - -skip_list: - - command-instead-of-module - - command-instead-of-shell - - line-length - - risky-shell-pipe - - no-changed-when - - no-handler - - ignore-errors diff --git a/.github/workflows/ansible-lint-sap_software_download.yml b/.github/workflows/ansible-lint-sap_software_download.yml new file mode 100644 index 0000000..76eee1e --- /dev/null +++ b/.github/workflows/ansible-lint-sap_software_download.yml @@ -0,0 +1,36 @@ +--- +name: Ansible Lint - sap_software_download + +on: + push: + branches: + - main + - dev + paths: + - 'roles/sap_software_download/**' + pull_request: + branches: + - main + - dev + paths: + - 'roles/sap_software_download/**' + + workflow_dispatch: + +jobs: + ansible-lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + # Use @v25 to automatically track the latest release from the year 2025. + # ansible-lint uses Calendar Versioning (e.g., v25.9.0 -> YYYY.MM.PATCH). + # Avoid using @main, which can introduce breaking changes unexpectedly. + - uses: ansible/ansible-lint@v25 + with: + # v25.7.0 no longer uses 'working_directory' and role path is set in 'args'. + # Role specific .ansible-lint can be added with argument '-c'. + args: roles/sap_software_download + # Use the shared requirements file from the collection root for dependency context. + requirements_file: ./requirements.yml diff --git a/.github/workflows/ansible-lint.yml b/.github/workflows/ansible-lint.yml index b55e812..ba2473f 100644 --- a/.github/workflows/ansible-lint.yml +++ b/.github/workflows/ansible-lint.yml @@ -1,14 +1,24 @@ -name: Ansible Lint +--- +name: Ansible Lint - Collection -on: [push, pull_request] +on: + schedule: + # This is 03:05 UTC, which is 5:05 AM in Prague/CEST. + - cron: '5 3 * * 1' + + workflow_dispatch: jobs: ansible-lint: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Ansible Lint Action - uses: ansible/ansible-lint@v6 + # Use @v25 to automatically track the latest release from the year 2025. + # ansible-lint uses Calendar Versioning (e.g., v25.9.0 -> YYYY.MM.PATCH). + # Avoid using @main, which can introduce breaking changes unexpectedly. + - uses: ansible/ansible-lint@v25 + with: + # Use the shared requirements file from the collection root for dependency context. + requirements_file: ./requirements.yml diff --git a/.github/workflows/ansible-test-sanity.yml b/.github/workflows/ansible-test-sanity.yml new file mode 100644 index 0000000..aa249b9 --- /dev/null +++ b/.github/workflows/ansible-test-sanity.yml @@ -0,0 +1,84 @@ +--- +# Always check ansible-core support matrix before configuring units matrix. +# https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix + +name: Ansible Test - Sanity + +on: + schedule: + # This is 01:05 UTC, which is 3:05 AM in Prague/CEST + - cron: '5 3 * * 1' + + pull_request: + branches: + - main + - dev + + workflow_dispatch: + +jobs: + sanity-supported: + runs-on: ubuntu-latest + name: Sanity (Supported Ⓐ${{ matrix.ansible }}) + strategy: + fail-fast: false # Disabled so we can see all failed combinations. + # Define a build matrix to test compatibility across multiple Ansible versions. + # Each version listed below will spawn a separate job that runs in parallel. + matrix: + ansible: + - 'stable-2.18' # Python 3.11 - 3.13 + - 'stable-2.19' # Python 3.11 - 3.13 + - 'devel' # Test against the upcoming development version. + + steps: + - uses: actions/checkout@v5 + + - name: ansible-test - sanity + uses: ansible-community/ansible-test-gh-action@release/v1 + with: + ansible-core-version: ${{ matrix.ansible }} + testing-type: sanity + + sanity-eol: + runs-on: ubuntu-latest + # This job only runs if the supported tests pass + needs: sanity-supported + name: Sanity (EOL Ⓐ${{ matrix.ansible }}+py${{ matrix.python }}) + continue-on-error: true # This entire job is allowed to fail + strategy: + fail-fast: false # Disabled so we can see all failed combinations. + # Define a build matrix to test compatibility across multiple Ansible versions. + # Each version listed below will spawn a separate job that runs in parallel. + matrix: + ansible: + - 'stable-2.14' # Python 3.9 - 3.11 + - 'stable-2.15' # Python 3.9 - 3.11 + - 'stable-2.16' # Python 3.10 - 3.12 + - 'stable-2.17' # Python 3.10 - 3.12 + python: + - '3.9' + - '3.10' + - '3.11' + - '3.12' + exclude: + # Exclusions for incompatible Python versions. + - ansible: 'stable-2.14' + python: '3.12' + + - ansible: 'stable-2.15' + python: '3.12' + + - ansible: 'stable-2.16' + python: '3.9' + + - ansible: 'stable-2.17' + python: '3.9' + steps: + - uses: actions/checkout@v5 + + - name: ansible-test - sanity + uses: ansible-community/ansible-test-gh-action@release/v1 + with: + ansible-core-version: ${{ matrix.ansible }} + target-python-version: ${{ matrix.python }} + testing-type: sanity diff --git a/README.md b/README.md index 52724e5..536d26c 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ When an SAP User ID (e.g. S-User) is enabled with and part of an SAP Universal I - the SAP User ID - the password for login with the SAP Universal ID -In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID ‘Account Password’ in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. +In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID `Account Password` in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. For further information regarding connection errors, please see the FAQ section [Errors with prefix 'SAP SSO authentication failed - '](./docs/FAQ.md#errors-with-prefix-sap-sso-authentication-failed---). diff --git a/docs/FAQ.md b/docs/FAQ.md index ca093b9..bbe4c84 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -20,7 +20,7 @@ The error HTTP 401 refers to either: - Unauthorized, the SAP User ID being used belongs to an SAP Company Number (SCN) with one or more Installation Number/s which do not have license agreements for these files - Unauthorized, the SAP User ID being used does not have SAP Download authorizations - Unauthorized, the SAP User ID is part of an SAP Universal ID and must use the password of the SAP Universal ID - - In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID ‘Account Password’ in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. + - In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID `Account Password` in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. This is documented under [Execution - Credentials](https://github.com/sap-linuxlab/community.sap_launchpad#requirements-dependencies-and-testing). diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 5c26609..121f7a0 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -1,16 +1,57 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import json import re +from functools import wraps 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 +try: + from bs4 import BeautifulSoup +except ImportError: + HAS_BS4 = False + BeautifulSoup = None +else: + HAS_BS4 = True + +try: + from requests.models import HTTPError +except ImportError: + HAS_REQUESTS = False + HTTPError = None +else: + HAS_REQUESTS = True + _GIGYA_SDK_BUILD_NUMBER = None +def require_bs4(func): + # A decorator to check for the 'beautifulsoup4' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_BS4: + raise ImportError("The 'beautifulsoup4' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise ImportError("The 'requests' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +@require_requests +@require_bs4 def login(client, username, password): # Main authentication function. # @@ -57,18 +98,20 @@ def login(client, username, password): 'samlContext': params['samlContext'] } endpoint, meta = get_sso_endpoint_meta(client, idp_endpoint, - params=context, - allow_redirects=False) + 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) + data=meta, + headers=C.GIGYA_HEADERS, + allow_redirects=False) client.post(endpoint, data=meta, headers=C.GIGYA_HEADERS) +@require_requests +@require_bs4 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' @@ -100,6 +143,7 @@ def get_sso_endpoint_meta(client, url, **kwargs): return (endpoint, metadata) +@require_requests 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) @@ -109,6 +153,7 @@ def _get_gigya_login_params(client, url, data): return params +@require_requests 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'], @@ -120,10 +165,11 @@ def _gigya_websdk_bootstrap(client, params): }) client.get(C.URL_ACCOUNT_CDC_API + '/accounts.webSdkBootstrap', - params=params, - headers=C.GIGYA_HEADERS) + params=params, + headers=C.GIGYA_HEADERS) +@require_requests 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. @@ -154,6 +200,7 @@ def _gigya_login(client, username, password, api_key): return login_response.get('login_token') +@require_requests def _get_id_token(client, saml_params, login_token): # Exchanges a Gigya login token for a JWT ID token. query_params = { @@ -166,6 +213,7 @@ def _get_id_token(client, saml_params, login_token): return token +@require_requests def _get_uid(client, saml_params, login_token): # Retrieves the user's unique ID (UID) using the login token. query_params = { @@ -177,6 +225,7 @@ def _get_uid(client, saml_params, login_token): return uid +@require_requests 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}' @@ -187,16 +236,18 @@ def _get_uid_details(client, uid, id_token): return uid_details_response +@require_requests 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(): + for _account_type, v in accounts.items(): linked.extend(v['linkedAccounts']) return len(linked) > 1 +@require_requests 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' @@ -207,6 +258,7 @@ def _select_account(client, uid, sid, id_token): return client.request('PUT', url, headers=headers, json=data) +@require_requests 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 @@ -224,6 +276,7 @@ def _get_sdk_build_number(client, api_key): return build_number +@require_requests 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)) diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 9bd19e1..10e6598 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -1,14 +1,37 @@ -import requests +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import re -import urllib3 from urllib.parse import urlparse -from requests.adapters import HTTPAdapter from .constants import COMMON_HEADERS - -class _SessionAllowBasicAuthRedirects(requests.Session): +try: + import requests + from requests.adapters import HTTPAdapter + _RequestsSession = requests.Session +except ImportError: + HAS_REQUESTS = False + # Placeholders to prevent errors on module load + requests = None + HTTPAdapter = object + _RequestsSession = object +else: + HAS_REQUESTS = True + +try: + import urllib3 +except ImportError: + HAS_URLLIB3 = False + # Placeholder to prevent errors on module load + urllib3 = None +else: + HAS_URLLIB3 = True + + +class _SessionAllowBasicAuthRedirects(_RequestsSession): # 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 @@ -17,16 +40,21 @@ class _SessionAllowBasicAuthRedirects(requests.Session): # for sap.com domains. # This is only required for legacy API. def rebuild_auth(self, prepared_request, response): - if 'Authorization' in prepared_request.headers: + # The parent class might not be a real requests.Session if requests is not installed. + if HAS_REQUESTS and '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. + if not HAS_URLLIB3: + return False + urllib3_version = urllib3.__version__.split('.') if len(urllib3_version) == 2: urllib3_version.append('0') @@ -43,6 +71,11 @@ class ApiClient: # object-oriented interface for making API requests, replacing the # previous global session and request functions. def __init__(self): + if not HAS_REQUESTS: + raise ImportError("The 'requests' library is required but was not found.") + if not HAS_URLLIB3: + raise ImportError("The 'urllib3' library is required but was not found.") + self.session = _SessionAllowBasicAuthRedirects() # Configure retry logic for the session. @@ -114,4 +147,4 @@ 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 + return self.session.cookies diff --git a/plugins/module_utils/exceptions.py b/plugins/module_utils/exceptions.py index a50054b..71a4678 100644 --- a/plugins/module_utils/exceptions.py +++ b/plugins/module_utils/exceptions.py @@ -1,3 +1,7 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + # Custom exceptions for the sap_launchpad collection. @@ -24,4 +28,3 @@ class DownloadError(SapLaunchpadError): class FileNotFoundError(SapLaunchpadError): # Raised when a searched file cannot be found. pass - diff --git a/plugins/module_utils/maintenance_planner/__init__.py b/plugins/module_utils/maintenance_planner/__init__.py index 9b5afe6..e69de29 100644 --- a/plugins/module_utils/maintenance_planner/__init__.py +++ b/plugins/module_utils/maintenance_planner/__init__.py @@ -1 +0,0 @@ -# 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 index 29e871e..0fb3666 100644 --- a/plugins/module_utils/maintenance_planner/api.py +++ b/plugins/module_utils/maintenance_planner/api.py @@ -1,20 +1,77 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import re import time from html import unescape +from functools import wraps 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 +try: + from bs4 import BeautifulSoup +except ImportError: + HAS_BS4 = False + BeautifulSoup = None +else: + HAS_BS4 = True + +try: + from lxml import etree +except ImportError: + HAS_LXML = False + etree = None +else: + HAS_LXML = True + +try: + from requests.exceptions import HTTPError +except ImportError: + HAS_REQUESTS = False + HTTPError = None +else: + HAS_REQUESTS = True + # Module-level cache _MP_XSRF_TOKEN = None _MP_TRANSACTIONS = None _MP_NAMESPACE = 'http://xml.sap.com/2012/01/mnp' +def require_bs4(func): + # A decorator to check for the 'beautifulsoup4' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_BS4: + raise ImportError("The 'beautifulsoup4' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +def require_lxml(func): + # A decorator to check for the 'lxml' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_LXML: + raise ImportError("The 'lxml' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise ImportError("The 'requests' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + def auth_userapps(client): # Authenticates against userapps.support.sap.com to establish a session. _clear_mp_cookies(client, 'userapps') @@ -32,6 +89,7 @@ def auth_userapps(client): client.post(endpoint, data=meta) +@require_bs4 def get_transactions(client): # Retrieves a list of all available Maintenance Planner transactions. global _MP_TRANSACTIONS @@ -67,7 +125,9 @@ def get_transaction_id(client, name): raise exceptions.FileNotFoundError(f"Transaction '{name}' not found by name or display ID.") -def get_transaction_filename_url(client, trans_id): +@require_lxml +@require_requests +def get_transaction_filename_url(client, trans_id, validate_url=False): # 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')) @@ -83,6 +143,15 @@ def get_transaction_filename_url(client, trans_id): file_id = urljoin(C.URL_SOFTWARE_DOWNLOAD, '/file/' + f.get('id')) file_name = f.get('label') files.append((file_id, file_name)) + + if validate_url: + for pair in files: + url = pair[0] + try: + client.head(url) + except HTTPError: + raise exceptions.DownloadError('Download link is not available: {0}'.format(url)) + return files @@ -175,6 +244,7 @@ def _get_transaction(client, key, value): raise exceptions.FileNotFoundError(f"Transaction with {key}='{value}' not found.") +@require_lxml def _build_mnp_xml(**params): # Constructs the MNP XML payload for API requests. mnp = f'{{{_MP_NAMESPACE}}}' @@ -199,4 +269,4 @@ 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 + client.session.cookies.clear(domain=cookie.domain) diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py index 198de8b..f027df5 100644 --- a/plugins/module_utils/maintenance_planner/main.py +++ b/plugins/module_utils/maintenance_planner/main.py @@ -1,9 +1,12 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import pathlib from .. import auth, exceptions from ..client import ApiClient from . import api -from requests.exceptions import HTTPError def run_files(params): @@ -14,32 +17,36 @@ def run_files(params): msg='' ) - client = ApiClient() - username = params['suser_id'] - password = params['suser_password'] - transaction_name = params['transaction_name'] - validate_url = params['validate_url'] - try: + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + transaction_name = params['transaction_name'] + validate_url = params['validate_url'] + 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}') + download_basket_details = api.get_transaction_filename_url(client, transaction_id, validate_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: + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + elif 'lxml' in str(e): + result['missing_dependency'] = 'lxml' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) + except exceptions.SapLaunchpadError as e: result['failed'] = True result['msg'] = str(e) except Exception as e: @@ -56,13 +63,13 @@ def run_stack_xml_download(params): msg='' ) - client = ApiClient() - username = params['suser_id'] - password = params['suser_password'] - transaction_name = params['transaction_name'] - dest = params['dest'] - try: + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + transaction_name = params['transaction_name'] + dest = params['dest'] + auth.login(client, username, password) api.auth_userapps(client) @@ -91,11 +98,21 @@ def run_stack_xml_download(params): result['changed'] = True result['msg'] = f"SAP Maintenance Planner Stack XML successfully downloaded to {output_file}" - except (exceptions.SapLaunchpadError, HTTPError) as e: + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e) or 'lxml' in str(e): + result['missing_dependency'] = 'beautifulsoup4 and/or lxml' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) + 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: {e}" - return result \ No newline at end of file + return result diff --git a/plugins/module_utils/software_center/__init__.py b/plugins/module_utils/software_center/__init__.py index 6a9cf69..e69de29 100644 --- a/plugins/module_utils/software_center/__init__.py +++ b/plugins/module_utils/software_center/__init__.py @@ -1 +0,0 @@ -# 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 8962bcd..7f44ffc 100644 --- a/plugins/module_utils/software_center/download.py +++ b/plugins/module_utils/software_center/download.py @@ -1,18 +1,40 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import glob import hashlib import os import time - -from requests.exceptions import ConnectionError, HTTPError +from functools import wraps from .. import auth from .. import constants as C from .. import exceptions from . import search +try: + from requests.exceptions import ConnectionError, HTTPError +except ImportError: + HAS_REQUESTS = False + ConnectionError, HTTPError = None, None +else: + HAS_REQUESTS = True + _HAS_DOWNLOAD_AUTHORIZATION = None +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise ImportError("The 'requests' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +@require_requests 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 additional context. @@ -74,6 +96,7 @@ def check_similar_files(dest, filename): return False, [] +@require_requests def _check_download_authorization(client): # Verifies that the authenticated user has the "Software Download" authorization. # Caches the result to avoid repeated API calls. @@ -102,6 +125,7 @@ def _check_download_authorization(client): ) +@require_requests 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. @@ -119,6 +143,7 @@ def is_download_link_available(client, url, retry=0): return None +@require_requests 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. @@ -150,6 +175,7 @@ def _resolve_download_link(client, url, retry=0): return endpoint +@require_requests 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}) diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py index 539b40b..cbd5b03 100644 --- a/plugins/module_utils/software_center/main.py +++ b/plugins/module_utils/software_center/main.py @@ -1,3 +1,7 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import os from .. import auth @@ -64,8 +68,8 @@ def run_software_download(params): result['msg'] = f"Similar file(s) already exist: {', '.join(filename_similar_names)}" return result - client = ApiClient() try: + client = ApiClient() auth.login(client, username, password) validation_result = None @@ -149,13 +153,28 @@ def run_software_download(params): 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" + result['msg'] = ( + f"Successfully downloaded alternative SAP software: {download_filename}" + f" - 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 ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + elif 'lxml' in str(e): + result['missing_dependency'] = 'lxml' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) except exceptions.SapLaunchpadError as e: result['failed'] = True result['msg'] = str(e) @@ -165,4 +184,4 @@ def run_software_download(params): finally: download.clear_download_key_cookie(client) - return result \ No newline at end of file + return result diff --git a/plugins/module_utils/software_center/search.py b/plugins/module_utils/software_center/search.py index 535e8ac..7f83f6d 100644 --- a/plugins/module_utils/software_center/search.py +++ b/plugins/module_utils/software_center/search.py @@ -1,4 +1,7 @@ -import csv +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import json import os import re @@ -168,7 +171,7 @@ def _prepare_search_filename_specific(filename): if filename_base.startswith(swpm_version): return swpm_version - # Example: SUM11SP04_2-80006858.SAR returns SUM11SP04 + # Example: SUM11SP04_2-80006858.SAR returns SUM11SP04 if filename_base.startswith('SUM'): return filename.split('-')[0].split('_')[0] @@ -294,7 +297,7 @@ 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('|') + _prefix, url = desc.split('|') return url.strip() diff --git a/plugins/module_utils/systems/__init__.py b/plugins/module_utils/systems/__init__.py index 67a78bd..e69de29 100644 --- a/plugins/module_utils/systems/__init__.py +++ b/plugins/module_utils/systems/__init__.py @@ -1 +0,0 @@ -# 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 index 0de1dae..66ee143 100644 --- a/plugins/module_utils/systems/api.py +++ b/plugins/module_utils/systems/api.py @@ -1,8 +1,12 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import json import time +from functools import wraps from urllib.parse import urljoin -from requests.exceptions import HTTPError from .. import constants as C from .. import exceptions @@ -49,15 +53,39 @@ def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_i 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}") + super().__init__( + f"Invalid data for {scope}: Unknown fields: {unknown_fields}, " + f"Missing required fields: {missing_required_fields}, Invalid options: {fields_with_invalid_option}" + ) + + +try: + from requests.exceptions import HTTPError +except ImportError: + HAS_REQUESTS = False + HTTPError = None +else: + HAS_REQUESTS = True +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise ImportError("The 'requests' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +@require_requests 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'] +@require_requests 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}'" @@ -76,11 +104,15 @@ def get_system(client, system_nr, installation_nr, username): 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}") + raise exceptions.SapLaunchpadError( + f"System {system_nr} was found, but it is missing a required Product Version ID " + f"(checked for 'Prodver' and 'Version' keys). System details: {system}" + ) return system +@require_requests 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 ''" @@ -91,6 +123,7 @@ def get_product_id(client, product_name, installation_nr, username): return product['Product'] +@require_requests 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 ''" @@ -101,6 +134,7 @@ def get_version_id(client, version_name, product_id, installation_nr, username): return version['Version'] +@require_requests 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 ''" @@ -109,6 +143,7 @@ def validate_installation(client, installation_nr, username): raise InstallationNotFoundError(installation_nr, [i['Insnr'] for i in installations]) +@require_requests 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}'" @@ -132,6 +167,7 @@ def validate_system_data(client, data, version_id, system_nr, installation_nr, u return final_fields_lower, warning +@require_requests 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'" @@ -152,6 +188,7 @@ def validate_licenses(client, licenses, version_id, installation_nr, username): return license_data +@require_requests 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. @@ -168,6 +205,7 @@ def get_existing_licenses(client, system_nr, username): ] +@require_requests def generate_licenses(client, license_data, existing_licenses, version_id, installation_nr, username): # Generates new license keys for a system. body = { @@ -183,6 +221,7 @@ def generate_licenses(client, license_data, existing_licenses, version_id, insta return json.loads(response['d']['Result']) +@require_requests 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`) @@ -212,6 +251,7 @@ def submit_system(client, is_new, system_data, generated_licenses, username): return licdata[0]['VALUE'] +@require_requests 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 = [] @@ -236,12 +276,14 @@ def get_license_key_numbers(client, license_data, system_nr, username): return key_nrs +@require_requests 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 +@require_requests def delete_licenses(client, licenses_to_delete, existing_licenses, version_id, installation_nr, username): # Deletes a list of specified licenses from a system. body = { @@ -267,6 +309,7 @@ def _headers(additional_headers): return {**{'Accept': 'application/json'}, **additional_headers} +@require_requests 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. @@ -318,4 +361,4 @@ def _validate_user_data_against_supported_fields(scope, user_data, possible_fiel 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 + return final_fields diff --git a/plugins/module_utils/systems/main.py b/plugins/module_utils/systems/main.py index 0060d8a..cec99c0 100644 --- a/plugins/module_utils/systems/main.py +++ b/plugins/module_utils/systems/main.py @@ -1,6 +1,8 @@ -import os +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + import pathlib -from requests.exceptions import HTTPError from .. import auth, exceptions from ..client import ApiClient @@ -10,10 +12,21 @@ def run_systems_info(params): # Main runner function for the systems_info module. result = {'changed': False, 'failed': False, 'systems': []} - client = ApiClient() + try: + client = ApiClient() auth.login(client, params['suser_id'], params['suser_password']) result['systems'] = api.get_systems(client, params['filter']) + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) except (exceptions.SapLaunchpadError, api.SystemNotFoundError) as e: result['failed'] = True result['msg'] = str(e) @@ -23,14 +36,15 @@ def run_systems_info(params): 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: + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + installation_nr = params['installation_nr'] + system_nr = params['system_nr'] + state = params['state'] + auth.login(client, username, password) api.validate_installation(client, installation_nr, username) @@ -109,7 +123,12 @@ def run_license_keys(params): 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)] + 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." @@ -126,7 +145,12 @@ def run_license_keys(params): 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)] + 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: @@ -168,6 +192,17 @@ def run_license_keys(params): result['failed'] = True result['msg'] = f"Failed to write license file: {e}" + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) + except (exceptions.SapLaunchpadError, api.InstallationNotFoundError, api.SystemNotFoundError, @@ -182,4 +217,4 @@ def run_license_keys(params): result['failed'] = True result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" - return result \ No newline at end of file + return result diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 344b976..25a4af3 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -12,7 +12,7 @@ description: - This ansible module creates and updates systems and their license keys using the Launchpad API. - - It is closely modeled after the interactions in the portal U(https://me.sap.com/licensekey): + - It is closely modeled after the interactions in the portal U(https://me.sap.com/licensekey) - First, a SAP system is defined by its SID, product, version and other data. - Then, for this system, license keys are defined by license type, HW key and potential other attributes. - The system and license data is then validated and submitted to the Launchpad API and the license key files returned to the caller. @@ -31,9 +31,8 @@ - SAP S-User Password. required: true type: str - no_log: true installation_nr: - description: + description: - Number of the Installation for which the system should be created/updated required: true type: str @@ -59,7 +58,7 @@ required: true type: str data: - description: + description: - The data attributes of the system. The possible attributes are defined by product and version. - Running the module without any data attributes will return in the error message which attributes are supported/required. required: true @@ -80,13 +79,13 @@ required: true type: str data: - description: + description: - The data attributes of the licenses. The possible attributes are defined by product and version. - Running the module without any data attributes will return in the error message which attributes are supported/required - In practice, most license types require at least a hardware key (hwkey) and expiry date (expdate) required: true type: dict - + delete_other_licenses: description: - Whether licenses other than the ones specified in the licenses attributes should be deleted. @@ -100,7 +99,8 @@ type: path author: - - Lab for SAP Solutions + - Matthias Winzeler (@MatthiasWinzeler) + - Marcel Mamula (@marcelmamula) ''' @@ -169,13 +169,13 @@ SWPRODUCTLIMIT=2147483647 SYSTEM-NR=00000000023456789 system_nr: - description: The number of the system which was created/updated. + description: The number of the system which was created/updated. returned: on success type: str sample: "0000123456" ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.systems import main as systems_runner @@ -231,6 +231,8 @@ def run_module(): result = systems_runner.run_license_keys(params) if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index a2bd3f4..58460f1 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: maintenance_planner_files @@ -31,8 +33,14 @@ - Transaction Name or Transaction Display ID from Maintenance Planner. required: true type: str + validate_url: + description: + - Validates if the download URLs are accessible before returning them. + type: bool + default: false author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Marcel Mamula (@marcelmamula) ''' @@ -70,8 +78,7 @@ sample: "SAPCAR_1324-80000936.EXE" ''' -import requests -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.maintenance_planner import main as maintenance_planner_runner @@ -104,6 +111,8 @@ def run_module(): # The runner function indicates failure via a key in the result. if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index 4473078..dd54760 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: maintenance_planner_stack_xml_download @@ -37,7 +39,9 @@ required: true type: str author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Sean Freeman (@sean-freeman) + - Marcel Mamula (@marcelmamula) ''' @@ -62,8 +66,7 @@ sample: "SAP Maintenance Planner Stack XML successfully downloaded to /tmp/MP_STACK_20211015_044854.xml" ''' -import requests -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.maintenance_planner import main as maintenance_planner_runner @@ -97,6 +100,8 @@ def run_module(): # The runner function indicates failure via a key in the result. if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index c0bb800..7268659 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: software_center_download @@ -32,24 +34,25 @@ description: - "Deprecated. Use 'search_query' instead." required: false + default: '' type: str - deprecated: - alternative: search_query - removed_in: "1.2.0" search_query: description: - Filename of the SAP software to download. required: false + default: '' type: str download_link: description: - Direct download link to the SAP software. required: false + default: '' type: str download_filename: description: - Download filename of the SAP software. required: false + default: '' type: str dest: description: @@ -58,27 +61,35 @@ type: str deduplicate: description: - - "Specifies how to handle multiple search results for the same filename. Choices are `first` (oldest) or `last` (newest)." - choices: [ 'first', 'last' ] + - "Specifies how to handle multiple search results for the same filename. + - Choices are `first` (oldest) or `last` (newest)." + choices: [ 'first', 'last', '' ] required: false + default: '' type: str search_alternatives: description: - Enable search for alternative packages, when filename is not available. required: false + default: false type: bool dry_run: description: - Check availability of SAP Software without downloading. required: false + default: 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. + - 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 + default: false type: bool author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Sean Freeman (@sean-freeman) + - Marcel Mamula (@marcelmamula) ''' @@ -132,7 +143,7 @@ type: bool ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.software_center import main as software_center_runner @@ -170,6 +181,8 @@ def run_module(): # The runner function indicates failure via a key in the result. if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index be25d5f..2817938 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: systems_info @@ -25,14 +27,14 @@ - 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: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Marcel Mamula (@marcelmamula) ''' @@ -67,7 +69,7 @@ Version: "73554900100800000266" ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.systems import main as systems_runner @@ -86,6 +88,8 @@ def run_module(): result = systems_runner.run_systems_info(module.params) if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.14.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.15.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.16.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.17.txt b/tests/sanity/ignore-2.17.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.17.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.18.txt b/tests/sanity/ignore-2.18.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.18.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.19.txt b/tests/sanity/ignore-2.19.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.19.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.20.txt b/tests/sanity/ignore-2.20.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.20.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file From 10e87ab43499937d10a66c60923b7a8fc51a5004 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Wed, 8 Oct 2025 12:05:56 +0200 Subject: [PATCH 2/3] remove trailing comma for trailing-comma-tuple --- plugins/module_utils/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 121f7a0..e7b51ba 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -156,7 +156,7 @@ def _get_gigya_login_params(client, url, data): @require_requests 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'], + page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'] params.update({ 'pageURL': page_url, 'sdk': 'js_latest', From 37c6e50ad69b40937d064968e8669a97a1887e0f Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Wed, 8 Oct 2025 12:13:20 +0200 Subject: [PATCH 3/3] fix missing numbering for ansible-format-automatic-specification --- plugins/module_utils/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index e7b51ba..9cce0d9 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -304,7 +304,7 @@ def _cdc_api_request(client, endpoint, saml_params, query_params): error_code = json_response['errorCode'] if error_code != 0: - http_error_msg = '{} Error: {} for url: {}'.format( + http_error_msg = '{0} Error: {1} for url: {2}'.format( json_response['statusCode'], json_response['errorMessage'], res.url) raise HTTPError(http_error_msg, response=res) return json_response