diff --git a/.github/workflows/ansible-lint.yml b/.github/workflows/ansible-lint.yml
new file mode 100644
index 0000000..f473ebd
--- /dev/null
+++ b/.github/workflows/ansible-lint.yml
@@ -0,0 +1,23 @@
+name: Ansible Lint
+
+on: [push, pull_request]
+
+jobs:
+ ansible-lint:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Lint Ansible Playbook
+ uses: ansible/ansible-lint-action@master
+ with:
+ targets: |
+ ./
+ override-deps: |
+ ansible==4.8.0
+ ansible-core==2.11.6
+ ansible-lint==5.2.1
+
+# Static: use Ansible Community Edition 4.8.0, with lowest compatible Ansible Core 2.11.6 and use Ansible-lint 5.2.1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4938417
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,54 @@
+# .gitignore defines files to ignore and remain untracked in Git
+# Each line in a gitignore file specifies a pattern, eg. directory or file extension
+
+# Git should not track binary artifacts such as images, libraries, executables, archive files etc.
+# Until the team has mature processes, a Binary Artifacts Repository Manager is not in use.
+
+# Therefore some binary artifacts are tracked in this Git repository
+
+
+# Further .gitignore templates available at:
+# https://github.com/github/gitignore
+
+
+# macOS OS generated files
+.DS_Store
+._*
+.Spotlight-V100
+.Trashes
+
+# Windows OS generated files #
+ehthumbs.db
+Thumbs.db
+
+# Compressed Archives
+# git has built-in compression
+# *.7z
+# *.dmg
+# *.gz
+# *.iso
+# *.jar
+# *.rar
+# *.tar
+# *.zip
+
+# Binaries / Compiled source
+# *.com
+# *.class
+# *.dll
+# *.exe
+# *.o
+# *.so
+
+# Logs and databases
+# *.log
+# *.sqlite
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# VSCode
+.vscode
+
diff --git a/LICENSE b/LICENSE
new file mode 100755
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4ebcb3c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# community.sap_launchpad Ansible Collection ![Ansible Lint](https://github.com/infrasap/community.sap_launchpad/workflows/Ansible%20Lint/badge.svg?branch=main)
+
+This Ansible Collection executes basic SAP.com Support operations tasks.
+
+## Functionality
+
+This Ansible Collection executes basic SAP.com Support operations tasks, including:
+
+- **Software Center Catalog**
+ - Search and Download of SAP software center catalog files
+ - Search and Extraction of SAP software center catalog information
+- **Maintenance Planner**
+ - Lookup and download files from an existing 'New Implementation' MP Transaction and Stack, using SAP software center's download basket
+
+## Contents
+
+An Ansible Playbook can call either an Ansible Role, or the individual Ansible Modules for handling the API calls to various SAP Support Portal API capabilities:
+- **Ansible Roles** (runs multiple Ansible Modules)
+- **Ansible Modules** (and adjoining Python Functions)
+
+For further information regarding the development, code structure and execution workflow please read the [Development documentation](./docs/DEVELOPMENT.md).
+
+Within this Ansible Collection, there are various Ansible Modules.
+
+#### Ansible Modules
+
+| Name | Summary |
+| :-- | :-- |
+| [sap_launchpad.software_center_download](./docs/module_software_center_download.md) | search for files and download |
+| [sap_launchpad.software_center_catalog](./docs/module_software_center_download.md) | catalog extraction and search |
+| [sap_launchpad.maintenance_planner](./docs/module_maintenance_planner.md) | maintenance planner stack generation |
+
+## Execution examples
+
+There are various methods to execute the Ansible Collection, dependant on the use case. For more information, see [Execution examples with code samples](./docs/EXEC_EXAMPLES.md) and the summary below:
+
+| Execution Scenario | Use Case | Target |
+| --- | --- | --- |
+| Ansible Playbook
-> source Ansible Collection
-> execute Ansible Task
--> run Ansible Module
---> run Python/Bash Functions | Simple executions with a few activities | Localhost or Remote |
+| Ansible Playbook
-> source Ansible Collection
-> execute Ansible Task
--> run Ansible Role
---> run Ansible Module
----> run Python/Bash Functions
--> run Ansible Role
---> ... | Complex executions with various interlinked activities;
run in parallel or sequentially | Localhost or Remote |
+| Python/Bash Functions | Simple testing or non-Ansible use cases | Localhost |
+
+## Requirements, Dependencies and Testing
+
+### SAP User ID credentials
+
+SAP software installation media must be obtained from SAP directly, and requires valid license agreements with SAP in order to access these files.
+
+An SAP Company Number (SCN) contains one or more Installation Number/s, providing licences for specified SAP Software. When an SAP User ID is created within the SAP Customer Number (SCN), the administrator must provide SAP Download authorizations for the SAP User ID.
+
+When an SAP User ID (e.g. S-User) is enabled with and part of an SAP Universal ID, then the `sap_launchpad` Ansible Collection **must** use:
+- the SAP User ID
+- the password for login with the SAP Universal ID
+
+In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID ‘Account Password’ in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts.
+
+### Operating System requirements
+
+Designed for Linux operating systems, e.g. RHEL.
+
+This role has not been tested and amended for SAP NetWeaver Application Server instantiations on IBM AIX or Windows Server.
+
+Assumptions for executing this role include:
+- Registered OS License and OS Package repositories are available (from the relevant content delivery network of the OS vendor)
+- Simultaneous Ansible Playbook executions will require amendment of Ansible Variable name `softwarecenter_search_list` shown in execution samples (containing the list of installation media to download). This avoids accidental global variable clashes from occuring in Ansible Playbooks executed from the same controller host with an inline Ansible Inventory against 'all' target hosts.
+
+### Python requirements
+
+Execution/Controller/Management host:
+- Python 3
+
+Target host:
+- Python 3, with Python Modules `beautifulsoup4 lxml requests` (see [Execution examples with code samples](./docs/EXEC_EXAMPLES.md))
+
+### Testing on execution/controller host
+
+**Tests with Ansible Core release versions:**
+- Ansible Core 2.11.5 community edition
+
+**Tests with Python release versions:**
+- Python 3.9.7 (i.e. CPython distribution)
+
+**Tests with Operating System release versions:**
+- RHEL 8.4
+- macOS 11.6 (Big Sur), with Homebrew used for Python 3.x via PyEnv
+
+### Testing on target/remote host
+
+**Tests with Operating System release versions:**
+- RHEL 8.2 for SAP
+
+**Tests with Python release versions:**
+- Python 3.6.x (i.e. CPython distribution), default for RHEL 8.x and SLES 15.x
+- Python 3.8.x (i.e. CPython distribution)
+
+## License
+
+- [Apache 2.0](./LICENSE)
+
+## Contributors
+
+Contributors to the Ansible Roles within this Ansible Collection, are shown within [/docs/contributors](./docs/CONTRIBUTORS.md).
diff --git a/docs/.gitkeep b/docs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md
new file mode 100644
index 0000000..3905cb4
--- /dev/null
+++ b/docs/CONTRIBUTORS.md
@@ -0,0 +1,10 @@
+# Development contributors
+
+- **IBM Lab for SAP Solutions**
+ - IBM Consulting
+ - **Jason Masipiquena** - Developer of Ansible Collection
+ - **Sheng Li Zhu** - Developer for Python Functions and API execution optimization
+ - IBM Cloud
+ - **Sean Freeman** - Origin developer of Python API constructs, and project owner
+- **SVA GmbH - System Vertrieb Alexander GmbH**
+ - **Rainer Leber** - User acceptance testing
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..465ae92
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,77 @@
+
+# Development of community.sap_launchpad Ansible Collection
+
+This Ansible Collection is developed with several design principles and code practices.
+
+## Code structure
+
+This Ansible Collection is heavily focused on Ansible Modules to perform required SAP Support API calls. The directory tree structure is shown below:
+```code
+collection/
+├── docs/
+├── meta/
+├── plugins/
+│ ├── modules/
+│ │ ├── users.py
+│ │ ├── software_center_download.py
+│ │ ├── software_center_catalog.py
+│ │ ├── maintenance_planner.py
+│ │ ├── licenses.py
+│ │ └── incidents.py
+│ └── module_utils/
+│ ├── sap_id_sso.py
+│ ├── sap_launchpad_software_center_download_runner.py
+│ ├── sap_launchpad_software_center_catalog_runner.py
+│ └── sap_launchpad_maintenance_planner_runner.py
+├── roles/
+├── playbooks/
+│ ├── sample-download-install-media.yml
+│ └── sample-maintenance-planner-download.yml
+├── tests/
+├── galaxy.yml
+└── README.md
+```
+
+## Execution logic
+
+This Ansible Collection is designed to be heavily re-usable for various SAP Support scenarios (both server-side and client-side), and avoid encapsulation of commands within Ansible's syntax; this ensures the scripts (and the sequence of commands) could be re-used manually or re-used by another automation framework.
+
+It is important to understand the execution flow by an Ansible Playbook to either an Ansible Role (with or without embedded Playbooks), an Ansible Task, or an Ansible Module (and contained Script files). Alternatively it is possible to call the script files manually.
+
+
+See examples below:
+
+### Ansible Playbook to call many Ansible Roles (and the contained interlinked Ansible Tasks)
+```code
+# Produce outcome scenario, using many interlinked tasks
+- Run: Ansible Playbook
+ - Run: Ansible Role
+ - Ansible Task
+ - Ansible Playbook 1..n
+ - Ansible Task
+ - execute custom Ansible Module
+ - execute specified Python Module Functions
+ - call APIs or CLIs/binaries
+ - Ansible Task
+ - Ansible Playbook 1..n
+ - Ansible Task
+ - subsequent OS commands using output from APIs or CLIs/binaries
+```
+
+### Ansible Playbook to call single set of Ansible Tasks
+```code
+# Produce outcome scenario, with single set of tasks
+- Run: Ansible Playbook
+ - Ansible Task
+ - execute custom Ansible Module
+ - execute specified Python Module Functions
+ - call APIs or CLIs/binaries
+```
+
+### Python Shell to call single Python Function
+```code
+# Produce outcome scenario manually with singular code execution
+- Run: Python Shell
+ - Import Python Module file for APIs or CLIs/binaries
+ - Execute specificed Python Functions
+```
diff --git a/docs/EXEC_EXAMPLES.md b/docs/EXEC_EXAMPLES.md
new file mode 100644
index 0000000..21e8cb9
--- /dev/null
+++ b/docs/EXEC_EXAMPLES.md
@@ -0,0 +1,194 @@
+# Execution examples
+
+## Execution example with Ansible Playbook calling Ansible Module
+
+**Ansible Playbook YAML, execute Ansible Module**
+```yaml
+---
+- hosts: all
+
+ collections:
+ - community.sap_launchpad
+
+ pre_tasks:
+ - name: Install Python package manager pip3 to system Python
+ yum:
+ name: python3-pip
+ state: present
+ - name: Install Python dependencies for Ansible Modules to system Python
+ pip:
+ name:
+ - urllib3
+ - requests
+ - beautifulsoup4
+ - lxml
+
+# Prompt for Ansible Variables
+ vars_prompt:
+ - name: suser_id
+ prompt: Please enter S-User
+ private: no
+ - name: suser_password
+ prompt: Please enter Password
+ private: yes
+
+# Define Ansible Variables
+ vars:
+ ansible_python_interpreter: python3
+ softwarecenter_search_list:
+ - 'SAPCAR_1324-80000936.EXE'
+ - 'HCMT_057_0-80003261.SAR'
+
+# Use task block to call Ansible Module
+ tasks:
+ - name: Execute Ansible Module to download SAP software
+ community.sap_launchpad.software_center_download:
+ suser_id: "{{ suser_id }}"
+ suser_password: "{{ suser_password }}"
+ softwarecenter_search_query: "{{ item }}"
+ dest: "/tmp/"
+ with_items: "{{ softwarecenter_search_list }}"
+```
+
+**Execution of Ansible Playbook, with in-line Ansible Inventory set as localhost**
+
+```shell
+# Install from local source directory for Ansible 2.11+
+ansible-galaxy collection install ./community.sap_launchpad
+
+# Workaround install from local source directory for Ansible 2.9.x
+# mv ./community.sap_launchpad ~/.ansible/collections/ansible_collections/community
+
+# Run Ansible Collection on localhost
+ansible-playbook --timeout 60 ./community.sap_launchpad/playbooks/sample-download-install-media.yml --inventory "localhost," --connection=local
+```
+
+## Execution example with Ansible Playbook calling Ansible Role
+
+**Ansible Playbook YAML, execute Ansible Role on target/remote host**
+```yaml
+---
+- hosts: all
+
+ collections:
+ - community.sap_launchpad
+
+ pre_tasks:
+ - name: Install Python package manager pip3 to system Python
+ ansible.builtin.package:
+ name: python3-pip
+ state: present
+ - name: Install Python dependencies for Ansible Modules to system Python
+ ansible.builtin.pip:
+ name:
+ - urllib3
+ - requests
+ - beautifulsoup4
+ - lxml
+
+# Prompt for Ansible Variables
+ vars_prompt:
+ - name: suser_id
+ prompt: Please enter S-User
+ private: no
+ - name: suser_password
+ prompt: Please enter Password
+ private: yes
+
+# Define Ansible Variables
+ vars:
+ ansible_python_interpreter: python3
+ softwarecenter_search_list:
+ - 'SAPCAR_1324-80000936.EXE'
+ - 'HCMT_057_0-80003261.SAR'
+
+# Option 1: Use roles declaration
+ roles:
+ - { role: community.sap_launchpad.software_center_download }
+
+# Option 2: Use sequential parse/execution, by using include_role inside Task block
+ tasks:
+ - name: Execute Ansible Role to download SAP software
+ include_role:
+ name: { role: community.sap_launchpad.software_center_download }
+ vars:
+ suser_id: "{{ suser_id }}"
+ suser_password: "{{ suser_password }}"
+ softwarecenter_search_query: "{{ item }}"
+ with_items: "{{ softwarecenter_search_list }}"
+
+# Option 3: Use task block with import_roles
+ tasks:
+ - name: Execute Ansible Role to download SAP software
+ import_roles:
+ name: { role: community.sap_launchpad.software_center_download }
+ vars:
+ suser_id: "{{ suser_id }}"
+ suser_password: "{{ suser_password }}"
+ softwarecenter_search_query: "{{ item }}"
+ with_items: "{{ softwarecenter_search_list }}"
+
+```
+
+**Execution of Ansible Playbook, with in-line Ansible Inventory of target/remote hosts**
+
+```shell
+# Install from local source directory for Ansible 2.11+
+ansible-galaxy collection install ./community.sap_launchpad
+
+# Workaround install from local source directory for Ansible 2.9.x
+# mv ./community.sap_launchpad ~/.ansible/collections/ansible_collections/community
+
+# SSH Connection details
+bastion_private_key_file="$PWD/bastion_rsa"
+bastion_host="169.0.40.4"
+bastion_port="50222"
+bastion_user="bastionuser"
+
+target_private_key_file="$PWD/vs_rsa"
+target_host="10.0.50.5"
+target_user="root"
+
+# Run Ansible Collection to target/remote hosts via Proxy/Bastion
+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 example with Python environment
+
+**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_launchpad_software_center_download_runner import *
+>>>
+>>> # Debug
+>>> # from module_utils.sap_api_common import debug_https
+>>> # debug_https()
+>>>
+>>> ## Perform API requests to SAP Support
+>>> username='S0000000'
+>>> password='password'
+>>> sap_sso_login(username, password)
+>>> 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_maintenance_planner.md b/docs/module_maintenance_planner.md
new file mode 100644
index 0000000..c6bf2c7
--- /dev/null
+++ b/docs/module_maintenance_planner.md
@@ -0,0 +1,2 @@
+# maintainance_planner Ansible Module
+
diff --git a/docs/module_software_center_download.md b/docs/module_software_center_download.md
new file mode 100644
index 0000000..8b7c939
--- /dev/null
+++ b/docs/module_software_center_download.md
@@ -0,0 +1,2 @@
+# software_center_download Ansible Module
+
diff --git a/galaxy.yml b/galaxy.yml
new file mode 100644
index 0000000..50224d5
--- /dev/null
+++ b/galaxy.yml
@@ -0,0 +1,61 @@
+### REQUIRED
+# The namespace of the collection. This can be a company/brand/organization or product namespace under which all
+# content lives. May only contain alphanumeric lowercase characters and underscores. Namespaces cannot start with
+# underscores or numbers and cannot contain consecutive underscores
+namespace: community
+
+# The name of the collection. Has the same character restrictions as 'namespace'
+name: sap_launchpad
+
+# The version of the collection. Must be compatible with semantic versioning
+version: 1.0.0
+
+# The path to the Markdown (.md) readme file. This path is relative to the root of the collection
+readme: README.md
+
+# A list of the collection's content authors. Can be just the name or in the format 'Full Name (url)
+authors:
+ - IBM Lab for SAP Solutions
+ - IBM Cloud for SAP
+ - IBM Consulting for SAP
+
+### OPTIONAL but strongly recommended
+# A short summary description of the collection
+description: Collection of Ansible Modules and Ansible Roles for SAP Support Portal APIs
+
+# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only
+# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file'
+license:
+- Apache-2.0
+
+# The path to the license file for the collection. This path is relative to the root of the collection. This key is
+# mutually exclusive with 'license'
+license_file: ''
+
+# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character
+# requirements as 'namespace' and 'name'
+tags: []
+
+# Collections that this collection requires to be installed for it to be usable. The key of the dict is the
+# collection label 'namespace.name'. The value is a version range
+# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version
+# range specifiers can be set and are separated by ','
+dependencies: {}
+
+# The URL of the originating SCM repository
+repository: http://example.com/repository
+
+# The URL to any online docs
+documentation: http://docs.example.com
+
+# The URL to the homepage of the collection/project
+homepage: http://example.com
+
+# The URL to the collection issue tracker
+issues: http://example.com/issue/tracker
+
+# A list of file glob-like patterns used to filter any files or directories that should not be included in the build
+# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This
+# uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry',
+# and '.git' are always filtered
+build_ignore: ['tests']
diff --git a/meta/.gitkeep b/meta/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/playbooks/.gitkeep b/playbooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/playbooks/sample-download-install-media.yml b/playbooks/sample-download-install-media.yml
new file mode 100644
index 0000000..d8977d8
--- /dev/null
+++ b/playbooks/sample-download-install-media.yml
@@ -0,0 +1,44 @@
+---
+- hosts: all
+
+ collections:
+ - community.sap_launchpad
+
+ pre_tasks:
+ - name: Install Python package manager pip3 to system Python
+ ansible.builtin.package:
+ name: python3-pip
+ state: present
+ - name: Install Python dependencies for Ansible Modules to system Python
+ ansible.builtin.pip:
+ name:
+ - urllib3
+ - requests
+ - beautifulsoup4
+ - lxml
+
+# Prompt for Ansible Variables
+ vars_prompt:
+ - name: suser_id
+ prompt: Please enter S-User
+ private: no
+ - name: suser_password
+ prompt: Please enter Password
+ private: yes
+
+# Define Ansible Variables
+ vars:
+ ansible_python_interpreter: python3
+ softwarecenter_search_list:
+ - 'SAPCAR_1324-80000936.EXE'
+ - 'HCMT_057_0-80003261.SAR'
+
+# Use task block to call Ansible Module
+ tasks:
+ - name: Execute Ansible Module to download SAP software
+ community.sap_launchpad.software_center_download:
+ suser_id: "{{ suser_id }}"
+ suser_password: "{{ suser_password }}"
+ softwarecenter_search_query: "{{ item }}"
+ dest: "/tmp/"
+ with_items: "{{ softwarecenter_search_list }}"
diff --git a/playbooks/sample-maintenance-planner-download.yml b/playbooks/sample-maintenance-planner-download.yml
new file mode 100644
index 0000000..b86252c
--- /dev/null
+++ b/playbooks/sample-maintenance-planner-download.yml
@@ -0,0 +1,45 @@
+---
+- hosts: all
+
+ collections:
+ - community.sap_launchpad
+
+# pre_tasks:
+
+# Prompt for Ansible Variables
+ vars_prompt:
+ - name: suser_id
+ prompt: Please enter S-User
+ private: no
+ - name: suser_password
+ prompt: Please enter Password
+ private: yes
+ - name: mp_transaction_name
+ prompt: Please enter MP transaction name
+ private: no
+
+# Define Ansible Variables
+ vars:
+ ansible_python_interpreter: python3
+
+# Use task block to call Ansible Module
+ tasks:
+ - name: Execute Ansible Module 'maintenance_planner' to get files from MP
+ community.sap_launchpad.maintenance_planner:
+ suser_id: "{{ suser_id }}"
+ suser_password: "{{ suser_password }}"
+ transaction_name: "{{ mp_transaction_name }}"
+ register: sap_maintenance_planner_basket_register
+
+ # - debug:
+ # msg:
+ # - "{{ sap_maintenance_planner_basket_register.download_basket }}"
+
+ - name: Execute Ansible Module 'software_center_download' to download files
+ community.sap_launchpad.software_center_download:
+ suser_id: "{{ suser_id }}"
+ suser_password: "{{ suser_password }}"
+ download_link: "{{ item.DirectLink }}"
+ download_filename: "{{ item.Filename }}"
+ dest: "/tmp/test"
+ loop: "{{ sap_maintenance_planner_basket_register.download_basket }}"
diff --git a/plugins/.gitkeep b/plugins/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/plugins/inventory/.gitkeep b/plugins/inventory/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/plugins/module_utils/.gitkeep b/plugins/module_utils/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/plugins/module_utils/README.md b/plugins/module_utils/README.md
new file mode 100644
index 0000000..4f1fc6d
--- /dev/null
+++ b/plugins/module_utils/README.md
@@ -0,0 +1,3 @@
+# 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/constants.py b/plugins/module_utils/constants.py
new file mode 100644
index 0000000..6ed89f5
--- /dev/null
+++ b/plugins/module_utils/constants.py
@@ -0,0 +1,35 @@
+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_ACCOUNT_ATTRIBUTES = 'https://launchpad.support.sap.com/services/account/attributes'
+URL_SERVICE_INCIDENT = 'https://launchpad.support.sap.com/services/odata/incidentws'
+URL_SERVICE_USER_ADMIN = 'https://launchpad.support.sap.com/services/odata/useradminsrv'
+
+# Maintainance Planner
+URL_MAINTAINANCE_PLANNER = 'https://maintenanceplanner.cfapps.eu10.hana.ondemand.com'
+URL_USERAPPS = 'https://userapps.support.sap.com/sap/support/mp/index.html'
+URL_USERAPP_MP_SERVICE = 'https://userapps.support.sap.com/sap/support/mnp/services'
+URL_LEGACY_MP_API = 'https://tech.support.sap.com/sap/support/mnp/services'
+
+# The following URLs are hardcoded for Gigya Auth.
+# TODO: Try to avoid them somehow.
+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'
+
+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 = {'User-Agent': USER_AGENT_CHROME}
+GIGYA_HEADERS = {
+ 'User-Agent': USER_AGENT_CHROME,
+ 'Origin': URL_ACCOUNT,
+ 'Referer': URL_ACCOUNT,
+ 'Accept': '*/*',
+}
diff --git a/plugins/module_utils/sap_api_common.py b/plugins/module_utils/sap_api_common.py
new file mode 100644
index 0000000..1570bd1
--- /dev/null
+++ b/plugins/module_utils/sap_api_common.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+# coding: utf-8
+
+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') else 'GET'
+ res = https_session.request(method, url, **kwargs)
+ 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=[500, 502, 503, 504],
+ 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
new file mode 100644
index 0000000..ba2b833
--- /dev/null
+++ b/plugins/module_utils/sap_id_sso.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+# coding: utf-8
+
+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):
+ if not re.match(r'^[sS]\d+$', username):
+ raise ValueError('Please login with SID (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 '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}')
+ 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
new file mode 100644
index 0000000..ec759e6
--- /dev/null
+++ b/plugins/module_utils/sap_launchpad_maintenance_planner_runner.py
@@ -0,0 +1,357 @@
+#!/user/bin/env python3
+# coding: utf-8
+
+import os
+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 requests.sessions import session
+
+from . import constants as C
+from .sap_api_common import _request, https_session
+from .sap_id_sso import _get_sso_endpoint_meta, sap_sso_login
+
+_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_MAINTAINANCE_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_MAINTAINANCE_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_MAINTAINANCE_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,
+ }
+
+ res = _mp_request(params=params)
+ xml = unescape(res.text.replace('\ufeff', ''))
+
+ if output_dir is None:
+ return xml
+
+ 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(xml)
+
+
+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 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(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
new file mode 100644
index 0000000..d582d5d
--- /dev/null
+++ b/plugins/module_utils/sap_launchpad_software_center_catalog_runner.py
@@ -0,0 +1,16 @@
+#!/user/bin/env python3
+# coding: utf-8
+
+from . import constants as C
+from .sap_api_common import _request
+from .sap_id_sso import sap_sso_login
+
+
+def get_software_catalog():
+ 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
new file mode 100644
index 0000000..2452e30
--- /dev/null
+++ b/plugins/module_utils/sap_launchpad_software_center_download_runner.py
@@ -0,0 +1,110 @@
+#!/user/bin/env python3
+# coding: utf-8
+
+import json
+import logging
+import os
+
+from requests.auth import HTTPBasicAuth
+
+from . import constants as C
+from .sap_api_common import _request
+from .sap_id_sso import _get_sso_endpoint_meta, sap_sso_login
+
+logger = logging.getLogger(__name__)
+
+_HAS_DOWNLOAD_AUTHORIZATION = None
+
+def search_software_filename(name):
+ """Return a single software that matched the filename
+ """
+ search_results = _search_software(name)
+ softwares = [r for r in search_results if r['Title'] == name]
+ if len(softwares) == 0:
+ raise ValueError(f'no result found for {name}')
+ if len(softwares) > 1:
+ names = [s['Title'] for s in softwares]
+ raise ValueError('more than one results were found: %s. '
+ 'please use the correct full filename' % names)
+ software = softwares[0]
+ download_link, filename = software['DownloadDirectLink'], software['Title']
+ return (download_link, filename)
+
+
+def download_software(download_link, filename, output_dir):
+ """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 = {}
+ while ('SAMLResponse' not in meta):
+ endpoint, meta = _get_sso_endpoint_meta(endpoint, data=meta)
+
+ filepath = os.path.join(output_dir, filename)
+
+ _download_file(endpoint, filepath, data=meta)
+
+
+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,
+ 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 softwares,
+ # 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, **kwargs):
+ # Read response as stream, in case the file is huge.
+ kwargs.update({'stream': True})
+ with _request(url, **kwargs) as r:
+ r.raise_for_status()
+ with open(filepath, 'wb') as f:
+ # 1MiB Chunk
+ for chunk in r.iter_content(chunk_size=1024 * 1024):
+ f.write(chunk)
+
+
+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_descs = [r['ObjectDesc'] for r in j['d']['results']]
+ _HAS_DOWNLOAD_AUTHORIZATION = "Software Download" in authorization_descs
+ return _HAS_DOWNLOAD_AUTHORIZATION
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
new file mode 100644
index 0000000..ff75952
--- /dev/null
+++ b/plugins/module_utils/sap_launchpad_software_center_download_search_fuzzy.py
@@ -0,0 +1,93 @@
+import csv
+import logging
+
+import requests
+
+from . import constants as C
+from .sap_api_common import _request
+from .sap_id_sso import sap_sso_login
+
+
+def search_software_fuzzy(query, max=None, csv_filename=None):
+ """Returns a list of dict for the software results.
+ """
+ results = _search_software(query)
+ num = 0
+
+ softwares = []
+ while True:
+ for r in results:
+ r = _remove_useless_keys(r)
+ softwares.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(softwares, csv_filename)
+ return
+ return softwares
+
+
+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/modules/.gitkeep b/plugins/modules/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/plugins/modules/README.md b/plugins/modules/README.md
new file mode 100644
index 0000000..7401ca3
--- /dev/null
+++ b/plugins/modules/README.md
@@ -0,0 +1,3 @@
+# Ansible Modules documentation
+
+Each Ansible Module has documentation underneath `/docs`.
\ No newline at end of file
diff --git a/plugins/modules/maintenance_planner.py b/plugins/modules/maintenance_planner.py
new file mode 100644
index 0000000..fabf0e3
--- /dev/null
+++ b/plugins/modules/maintenance_planner.py
@@ -0,0 +1,155 @@
+
+#!/usr/bin/python
+
+# SAP maintenance planner xml stack file download
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+DOCUMENTATION = r'''
+---
+module: maintenance_planner
+
+short_description: SAP Maintenance Planner
+
+version_added: 1.0.0
+
+options:
+ suser_id:
+ description:
+ - SAP S-User ID.
+ required: true
+ type: str
+ suser_password:
+ description:
+ - SAP S-User Password.
+ required: true
+ type: str
+ transaction_name:
+ description:
+ - Transaction name of your Maintenance Planner session.
+ required: true
+ type: str
+author:
+ - Lab for SAP Solutions
+
+'''
+
+EXAMPLES = r'''
+- name: Execute Ansible Module 'maintenance_planner' to get files from MP
+ community.sap_launchpad.sap_launchpad_software_center_download:
+ 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 }}"
+'''
+
+RETURN = r'''
+msg:
+ description: the status of the process
+ returned: always
+ type: str
+download_basket:
+ description: a json list of software download links and filenames from the MP transaction
+ returned: always
+ type: json list
+'''
+
+
+#########################
+
+import requests
+from ansible.module_utils.basic import AnsibleModule
+
+# Import runner
+from ..module_utils.sap_launchpad_maintenance_planner_runner import *
+
+def run_module():
+
+ # Define available arguments/parameters a user can pass to the module
+ module_args = dict(
+ suser_id=dict(type='str', required=True),
+ suser_password=dict(type='str', required=True, no_log=True),
+ transaction_name=dict(type='str', required=True)
+ )
+
+ # Define result dictionary objects to be passed back to Ansible
+ result = dict(
+ download_basket={},
+ changed=False,
+ msg=''
+ )
+
+ # Instantiate module
+ module = AnsibleModule(
+ argument_spec=module_args,
+ supports_check_mode=True
+ )
+
+ # Check mode
+ 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')
+
+ # 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_by_name(transaction_name)
+
+ # EXEC: Clear download basket of any existing download files (to avoid conflicts and duplicates)
+ clear_download_basket()
+
+ # EXEC: Add all software from MP stack to download basket
+ add_stack_download_files_to_basket(transaction_id)
+
+ # EXEC: Get a json list of download_links and download_filenames
+ download_basket_details = get_download_basket_url_filename()
+
+ # 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)
+
+
+def main():
+ run_module()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py
new file mode 100644
index 0000000..9ec9a3f
--- /dev/null
+++ b/plugins/modules/software_center_download.py
@@ -0,0 +1,164 @@
+
+#!/usr/bin/python
+
+# SAP software download module
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+DOCUMENTATION = r'''
+---
+module: software_center_download
+
+short_description: SAP software download
+
+version_added: 1.0.0
+
+options:
+ suser_id:
+ description:
+ - SAP S-User ID.
+ required: true
+ type: str
+ suser_password:
+ description:
+ - SAP S-User Password.
+ required: true
+ type: str
+ softwarecenter_search_query:
+ description:
+ - Filename of the SAP software to download.
+ required: false
+ type: str
+ download_link:
+ description:
+ - Direct download link to the SAP software.
+ required: false
+ type: str
+ download_filename:
+ description:
+ - Download filename of the SAP software.
+ required: false
+ type: str
+ dest:
+ description:
+ - Destination folder.
+ required: true
+ type: str
+author:
+ - Lab for SAP Solutions
+
+'''
+
+EXAMPLES = r'''
+- name: Download using search query
+ community.sap_launchpad.sap_launchpad_software_center_download:
+ suser_id: 'SXXXXXXXX'
+ suser_password: 'password'
+ softwarecenter_search_query:
+ - 'SAPCAR_1324-80000936.EXE'
+ dest: "/tmp/"
+- name: Download using direct link and filename
+ community.sap_launchpad.software_center_download:
+ suser_id: 'SXXXXXXXX'
+ suser_password: 'password'
+ download_link: 'https://softwaredownloads.sap.com/file/0010000000048502015'
+ download_filename: 'IW_FNDGC100.SAR'
+ dest: "/tmp/"
+'''
+
+RETURN = r'''
+msg:
+ description: the status of the process
+ returned: always
+ type: str
+'''
+
+
+#########################
+
+import requests
+from ansible.module_utils.basic import AnsibleModule
+
+# Import runner
+from ..module_utils.sap_launchpad_software_center_download_runner import *
+
+
+def run_module():
+
+ # Define available arguments/parameters a user can pass to the module
+ module_args = dict(
+ suser_id=dict(type='str', required=True),
+ suser_password=dict(type='str', required=True, no_log=True),
+ softwarecenter_search_query=dict(type='str', required=False, default=''),
+ download_link=dict(type='str', required=False, default=''),
+ download_filename=dict(type='str', required=False, default=''),
+ dest=dict(type='str', required=True)
+ )
+
+ # Define result dictionary objects to be passed back to Ansible
+ result = dict(
+ changed=False,
+ msg=''
+ )
+
+ # Instantiate module
+ module = AnsibleModule(
+ argument_spec=module_args,
+ supports_check_mode=True
+ )
+
+ # Check mode
+ 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')
+ query = module.params.get('softwarecenter_search_query')
+ download_link= module.params.get('download_link')
+ download_filename= module.params.get('download_filename')
+ dest = module.params.get('dest')
+
+ # Main run
+
+ try:
+ # EXEC: Retrieve login session, using Py Function from imported module in directory module_utils
+ session = sap_sso_login(username, password)
+
+ # EXEC: no query
+ # execute download_software with directlink and filename
+ if query == '':
+ download_software(download_link, download_filename, dest)
+
+ # EXEC: query
+ # execute search_software_filename first to get download link and filename
+ # execute download_software based on query result
+ if query != '':
+ query_result = search_software_filename(query)
+ download_software(*query_result, dest)
+
+ # Process return dictionary for Ansible
+ result['changed'] = True
+ result['msg'] = "SAP software download successful"
+
+ 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)
+
+
+def main():
+ run_module()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/roles/.gitkeep b/roles/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tests/.gitkeep b/tests/.gitkeep
new file mode 100644
index 0000000..e69de29