diff --git a/LICENSE b/LICENSE
old mode 100755
new mode 100644
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/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
new file mode 100644
index 0000000..d1718c0
--- /dev/null
+++ b/docs/module_license_keys.md
@@ -0,0 +1,327 @@
+# 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
+
+## 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
+> **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: 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
+```
+
+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
+```
+
+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 for Ansible Module license_keys
+ 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
+
+ - 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
+```
+
+Install prerequisites and create new SAP system using existing Python Virtual Environment `/tmp/python_venv`.
+```yaml
+---
+- name: Example play for Ansible Module license_keys
+ hosts: all
+ tasks:
+ - name: Install Python and Python package manager pip
+ ansible.builtin.package:
+ name:
+ - python311
+ - python311-pip
+ state: present
+
+ - name: 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"
+
+ - 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
+```
+
+## 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..7f331e8 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):
@@ -12,87 +14,72 @@ 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
-### Example
-Obtain list of SAP Software files
-```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
-```
+### Execution Flow
+The module follows a clear logic flow to retrieve the file list from a Maintenance Planner transaction.
-Obtain list of SAP Software files using 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 }}"
-```
+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.
-### Output format
-#### msg
-- _Type:_ `string`
+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.
-The status of execution.
+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.
-#### download_basket
-- _Type:_ `list` with elements of type `dictionary`
+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.
-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
-```
+5. **Return Data**:
+ * The module returns the final list of files as the `download_basket`, with each item containing a `DirectLink` and a `Filename`.
-## Further Information
-### Installation of prerequisites
-**All preparation steps are included in role `sap_launchpad.sap_software_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.
-Prerequisite preparation using Python 3.11 Virtual Environment `/tmp/python_venv` (Recommended)
+Obtain list of SAP Software files using existing System Python.
```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
- ansible.builtin.package:
- name:
- - python311
- - python311-pip
- state: present
+ - 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: 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"
+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:
+ - 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
+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 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
@@ -111,8 +98,69 @@ Prerequisite preparation using Python 3.11 system default
- python311-beautifulsoup4
- python311-lxml
state: present
+
+ - 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
+```
+
+Install prerequisites and 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:
+ - name: Install Python and Python package manager pip
+ ansible.builtin.package:
+ name:
+ - python311
+ - python311-pip
+ state: present
+
+ - name: 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"
+
+ - 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.
+
+#### 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
@@ -138,3 +186,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..9bab198 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):
@@ -12,53 +14,75 @@ 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
+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
+> **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
@@ -68,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
@@ -93,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
@@ -134,4 +185,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..8351918 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):
@@ -12,91 +16,127 @@ 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
+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
+> **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
- 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, 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
- 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)"
- search_alternatives: true
- deduplicate: "last"
+---
+- 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`
-
-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 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
@@ -106,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
@@ -131,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
@@ -188,10 +253,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 +267,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..5c0c8ce
--- /dev/null
+++ b/docs/module_systems_info.md
@@ -0,0 +1,162 @@
+# 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
+
+## 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
+> **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: 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
+```
+
+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 for Ansible Module systems_info
+ 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
+
+ - 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
+```
+
+Install prerequisites and get SAP system details using existing Python Virtual Environment `/tmp/python_venv`.
+```yaml
+---
+- name: Example play for Ansible Module systems_info
+ hosts: all
+ tasks:
+ - name: Install Python and Python package manager pip
+ ansible.builtin.package:
+ name:
+ - python311
+ - python311-pip
+ state: present
+
+ - name: 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"
+
+ - 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
+```
+
+### 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
+
+## 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.
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..5c26609
--- /dev/null
+++ b/plugins/module_utils/auth.py
@@ -0,0 +1,257 @@
+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(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:
+ 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..c916d05 100644
--- a/plugins/module_utils/constants.py
+++ b/plugins/module_utils/constants.py
@@ -1,36 +1,46 @@
+# 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
-URL_MAINTENANCE_PLANNER = 'https://maintenanceplanner.cfapps.eu10.hana.ondemand.com'
+
+# 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'
+# 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'
+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) '
'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/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_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_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/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/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/software_center/__init__.py b/plugins/module_utils/software_center/__init__.py
new file mode 100644
index 0000000..6a9cf69
--- /dev/null
+++ b/plugins/module_utils/software_center/__init__.py
@@ -0,0 +1 @@
+# 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
new file mode 100644
index 0000000..8962bcd
--- /dev/null
+++ b/plugins/module_utils/software_center/download.py
@@ -0,0 +1,203 @@
+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, search_alternatives=False):
+ # Validates a local file against the remote checksum from the server.
+ # 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:
+ 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)
+
+ 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:
+ 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):
+ result['validated'] = True
+ result['message'] = 'Local file checksum is valid.'
+ else:
+ result['validated'] = False
+ result['message'] = 'Local file checksum is invalid.'
+
+ except exceptions.SapLaunchpadError as e:
+ result['message'] = f'Checksum validation skipped: {e}'
+ return result
+
+
+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.
+ # 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='/')
+
+
+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..539b40b
--- /dev/null
+++ b/plugins/module_utils/software_center/main.py
@@ -0,0 +1,168 @@
+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,
+ 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 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)
+ 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..535e8ac
--- /dev/null
+++ b/plugins/module_utils/software_center/search.py
@@ -0,0 +1,311 @@
+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.
+ 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
+
+
+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_name
+
+
+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.
+ # 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']:
+ return software_found['Description']
+ else:
+ return software_found['Title']
+ else:
+ return software_found['Title']
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/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/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/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 9ec4e0f..c0bb800 100644
--- a/plugins/modules/software_center_download.py
+++ b/plugins/modules/software_center_download.py
@@ -1,16 +1,19 @@
-# -*- 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
-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
@@ -55,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:
@@ -68,6 +72,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 +84,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:
@@ -88,62 +96,44 @@
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
'''
-
-#########################
-
-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, []
+from ..module_utils.software_center import main as software_center_runner
def run_module():
@@ -158,8 +148,9 @@ 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=''),
- search_alternatives=dict(type='bool', required=False, default=False)
+ 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)
)
# Instantiate module
@@ -168,145 +159,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
+ module.exit_json(changed=False)
- 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)
+ result = software_center_runner.run_software_download(module.params)
- # 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)
+ # The runner function can also return warnings for the module to display.
+ for warning in result.pop('warnings', []):
+ module.warn(warning)
- # Search for existing files again with alternative filename
- if search_alternatives and alternative_found:
- result['filename'] = download_filename
- result['alternative'] = True
-
- 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
-
- 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():
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():
diff --git a/roles/sap_software_download/README.md b/roles/sap_software_download/README.md
index e26dbe0..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'
@@ -325,4 +328,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: