Skip to content

Commit 068832d

Browse files
authored
Merge pull request #68 from marcelmamula/hana_relationships
software_center_download: Refactor and overhauled relationship validation
2 parents 63d7ced + b912820 commit 068832d

31 files changed

Lines changed: 2732 additions & 852 deletions

plugins/module_utils/software_center/download.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,23 @@ def wrapper(*args, **kwargs):
3535

3636

3737
@require_requests
38-
def validate_local_file_checksum(client, local_filepath, query=None, download_link=None, deduplicate=None, search_alternatives=False):
38+
def validate_local_file_checksum(client, local_filepath, query=None, download_link=None, deduplicate=None, search_alternatives=False, search_upgrades=False):
3939
# Validates a local file against the remote checksum from the server.
4040
# Returns a dictionary with the validation status and additional context.
4141
result = {
4242
'validated': None,
4343
'message': '',
4444
'remote_filename': os.path.basename(local_filepath),
45-
'alternative_found': False
45+
'alternative_found': False,
46+
'search_method': 'exact'
4647
}
4748
try:
4849
if query:
49-
file_details = search.find_file(client, query, deduplicate, search_alternatives=search_alternatives)
50+
file_details = search.find_file(client, query, deduplicate, search_alternatives=search_alternatives, search_upgrades=search_upgrades)
5051
download_link = file_details['download_link']
5152
result['remote_filename'] = file_details['filename']
5253
result['alternative_found'] = file_details['alternative_found']
54+
result['search_method'] = file_details.get('search_method', 'exact')
5355

5456
download_link_final = _resolve_download_link(client, download_link)
5557

plugins/module_utils/software_center/main.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ def run_software_download(params):
2121
'failed': False,
2222
'msg': '',
2323
'filename': '',
24+
'original_query': '',
2425
'alternative': False,
26+
'search_method': 'exact',
2527
'warnings': []
2628
}
2729

@@ -33,6 +35,7 @@ def run_software_download(params):
3335
dry_run = params.get('dry_run')
3436
deduplicate = params.get('deduplicate')
3537
search_alternatives = params.get('search_alternatives')
38+
search_upgrades = params.get('search_upgrades')
3639
validate_checksum = params.get('validate_checksum')
3740

3841
if params['search_query']:
@@ -50,6 +53,11 @@ def run_software_download(params):
5053

5154
filename = query if query else download_filename
5255
result['filename'] = filename
56+
result['original_query'] = query if query else download_filename
57+
58+
# Set search_method for direct_link usage
59+
if download_link and download_filename:
60+
result['search_method'] = 'direct_link'
5361

5462
filepath = os.path.join(dest, filename)
5563

@@ -83,7 +91,8 @@ def run_software_download(params):
8391
query=query,
8492
download_link=download_link,
8593
deduplicate=deduplicate,
86-
search_alternatives=search_alternatives
94+
search_alternatives=search_alternatives,
95+
search_upgrades=search_upgrades
8796
)
8897

8998
is_valid = validation_result['validated']
@@ -108,13 +117,14 @@ def run_software_download(params):
108117

109118
alternative_found = False
110119
if query:
111-
file_details = search.find_file(client, query, deduplicate, search_alternatives)
120+
file_details = search.find_file(client, query, deduplicate, search_alternatives, search_upgrades)
112121
download_link = file_details['download_link']
113122
download_filename = file_details['filename']
114123
alternative_found = file_details['alternative_found']
115124

116125
result['filename'] = download_filename
117126
result['alternative'] = alternative_found
127+
result['search_method'] = file_details.get('search_method', 'exact')
118128

119129
alt_filepath = os.path.join(dest, download_filename)
120130
if filename != download_filename and os.path.exists(alt_filepath):

plugins/module_utils/software_center/search.py

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
from ..exceptions import FileNotFoundError
1111

1212

13-
def find_file(client, name, deduplicate, search_alternatives):
13+
def find_file(client, name, deduplicate, search_alternatives, search_upgrades=False):
1414
# Main search function to find a software file.
1515
# It performs a direct search and, if requested, a fuzzy search for alternatives.
1616
# Returns a dictionary with file details.
1717
alternative_found = False
18+
search_method = 'exact'
1819

1920
# First, attempt a direct search for the exact filename.
2021
software_search = _search_software(client, name)
@@ -89,7 +90,7 @@ def find_file(client, name, deduplicate, search_alternatives):
8990
)
9091

9192
try:
92-
software_fuzzy_found = _search_software_fuzzy(client, name)
93+
software_fuzzy_found = _search_software_fuzzy(client, name, search_upgrades)
9394
except Exception as e:
9495
# Handle cases where API returns errors due to overly broad queries
9596
if 'RetryError' in str(type(e).__name__) or '500 error' in str(e):
@@ -105,7 +106,7 @@ def find_file(client, name, deduplicate, search_alternatives):
105106
# Re-raise other exceptions
106107
raise
107108

108-
software_fuzzy_filtered, suggested_filename = _filter_fuzzy_search(software_fuzzy_found, name)
109+
software_fuzzy_filtered, suggested_filename, search_method = _filter_fuzzy_search(software_fuzzy_found, name, search_upgrades)
109110
if len(software_fuzzy_filtered) == 0:
110111
raise FileNotFoundError(
111112
f'File "{name}" is not available '
@@ -134,8 +135,8 @@ def find_file(client, name, deduplicate, search_alternatives):
134135
last_option = software_search_alternatives_filtered[-1]['Title']
135136

136137
raise FileNotFoundError(
137-
f'More than one alternative was found: '
138-
f'{", ".join(names)}.\n'
138+
f'More than one alternative was found: {alternatives_count} files\n'
139+
f'Files: {", ".join(names)}\n'
139140
f'Please use a more specific filename '
140141
f'or set deduplicate parameter.\n'
141142
f'Options for deduplicate:\n'
@@ -163,7 +164,8 @@ def find_file(client, name, deduplicate, search_alternatives):
163164
last_option = software_filtered[-1]['Title']
164165

165166
raise FileNotFoundError(
166-
f'More than one result was found: {", ".join(names)}.\n'
167+
f'More than one result was found: {files_count} files\n'
168+
f'Files: {", ".join(names)}\n'
167169
f'Please use the correct full filename '
168170
f'or set deduplicate parameter.\n'
169171
f'Options for deduplicate:\n'
@@ -181,7 +183,8 @@ def find_file(client, name, deduplicate, search_alternatives):
181183
return {
182184
'download_link': software_found['DownloadDirectLink'],
183185
'filename': _get_valid_filename(software_found),
184-
'alternative_found': alternative_found
186+
'alternative_found': alternative_found,
187+
'search_method': search_method
185188
}
186189

187190

@@ -207,7 +210,7 @@ def _search_software(client, keyword):
207210
return results
208211

209212

210-
def _search_software_fuzzy(client, query):
213+
def _search_software_fuzzy(client, query, search_upgrades=False):
211214
# Executes a fuzzy search to find alternative versions.
212215
# Strategy: Try prefix search first (more specific), fallback to ID search if needed.
213216
filename_base = os.path.splitext(query)[0]
@@ -218,7 +221,7 @@ def _search_software_fuzzy(client, query):
218221

219222
# Extract ID and prepare suggested filename prefix
220223
filename_id = filename_base.split('-')[-1]
221-
suggested_filename, suggested_filename_next, suggested_filename_base = _prepare_search_filename(query)
224+
suggested_filename, suggested_filename_next, suggested_filename_base = _prepare_search_filename(query, search_upgrades)
222225
has_wildcard = '*' in query
223226

224227
fuzzy_results = []
@@ -233,8 +236,12 @@ def _search_software_fuzzy(client, query):
233236
if f'-{filename_id}' in r.get('Title', ''):
234237
fuzzy_results.append(_remove_useless_keys(r))
235238

239+
# Check if prefix search hit the 50-result limit (may have missed results)
240+
# If yes, fallback to paginated ID search to ensure completeness
241+
if len(results) >= 50:
242+
fuzzy_results = [] # Clear partial results, will use paginated ID search below
236243
# If empty and suggested_filename_next exists, try incremented version
237-
if len(fuzzy_results) == 0 and suggested_filename_next:
244+
elif len(fuzzy_results) == 0 and suggested_filename_next:
238245
results = _search_software(client, suggested_filename_next)
239246
for r in results:
240247
if f'-{filename_id}' in r.get('Title', ''):
@@ -266,8 +273,11 @@ def _search_software_fuzzy(client, query):
266273
return fuzzy_results
267274

268275

269-
def _filter_fuzzy_search(fuzzy_results, filename):
276+
def _filter_fuzzy_search(fuzzy_results, filename, search_upgrades=False):
270277
# Filters fuzzy search output using the original filename.
278+
# Returns: (sorted_results, suggested_filename, search_method)
279+
search_method = 'alternative_default' # Default assumption
280+
271281
if '*' in filename:
272282
prefix, suffix = filename.split('*')
273283
suffix_base = os.path.splitext(suffix)[0]
@@ -277,7 +287,7 @@ def _filter_fuzzy_search(fuzzy_results, filename):
277287
]
278288
suggested_filename = prefix
279289
else:
280-
suggested_filename, suggested_filename_next, suggested_filename_base = _prepare_search_filename(filename)
290+
suggested_filename, suggested_filename_next, suggested_filename_base = _prepare_search_filename(filename, search_upgrades)
281291

282292
# Create result list with same version if available.
283293
fuzzy_results_filtered = [
@@ -294,6 +304,7 @@ def _filter_fuzzy_search(fuzzy_results, filename):
294304
# Update return suggested filename to incremented version if alternatives are found with it.
295305
if len(fuzzy_results_filtered) > 0:
296306
suggested_filename = suggested_filename_next
307+
search_method = 'alternative_increment'
297308

298309
# Attempt to create result list with broader base prefix.
299310
if len(fuzzy_results_filtered) == 0 and suggested_filename_base:
@@ -304,17 +315,18 @@ def _filter_fuzzy_search(fuzzy_results, filename):
304315
# Update return suggested filename to base if alternatives are found with it.
305316
if len(fuzzy_results_filtered) > 0:
306317
suggested_filename = suggested_filename_base
318+
search_method = 'alternative_base'
307319

308320
fuzzy_results_sorted = _sort_fuzzy_results(fuzzy_results_filtered)
309-
return fuzzy_results_sorted, suggested_filename
321+
return fuzzy_results_sorted, suggested_filename, search_method
310322

311323

312-
def _prepare_search_filename(filename):
324+
def _prepare_search_filename(filename, search_upgrades=False):
313325
# Prepares suggested search keywords for known products.
314326
# Returns triplet: (suggested, suggested_next, suggested_base)
315327
# - suggested: exact version match
316328
# - suggested_next: incremented version (safe)
317-
# - suggested_base: broader prefix (only for known safe files)
329+
# - suggested_base: broader prefix (only for known safe files or when search_upgrades=True)
318330

319331
# Filename without extension.
320332
filename_base = os.path.splitext(filename)[0]
@@ -345,29 +357,44 @@ def _prepare_search_filename(filename):
345357
return suggested, _increment_last_digits(suggested), None
346358

347359
# Revision version will be kept to ensure correct component versions.
348-
# Example: IMDB_SERVER20_067_4-80002046.SAR (Rev 67) returns (IMDB_SERVER20_067, None, None)
349-
# Example: IMDB_AFL20_077_0-80002045.SAR (Rev 77) returns (IMDB_AFL20_077, None, None)
350-
# Example: IMDB_AFL100_102P_41-10012328.SAR (Rev 102) returns (IMDB_AFL100_102, None, None)
351-
# Example: IMDB_LCAPPS_122P_3300-20010426.SAR (Rev 122) returns (IMDB_LCAPPS_122, None, None)
352-
# Example: IMDB_LCAPPS_2067P_400-80002183.SAR (Rev 67) returns (IMDB_LCAPPS_2067, None, None)
360+
# Example: IMDB_SERVER20_067_4-80002046.SAR returns (IMDB_SERVER20_067, None, None)
361+
# With search_upgrades: (IMDB_SERVER20_067, IMDB_SERVER20_06, IMDB_SERVER20_)
362+
# Example: IMDB_AFL20_077_0-80002045.SAR returns (IMDB_AFL20_077, None, None)
363+
# With search_upgrades: (IMDB_AFL20_077, IMDB_AFL20_07, IMDB_AFL20_)
353364
elif filename_base.startswith(('IMDB_SERVER', 'IMDB_AFL', 'IMDB_LCAPPS_1', 'IMDB_LCAPPS_2')):
354365
# Remove P from the 3rd element (index 2) to improve fuzzy search.
355366
if len(filename_parts) > 2:
356367
filename_parts[2] = filename_parts[2].rstrip('Pp')
357368
# Re-join the first three elements -> "IMDB_AFL100_102"
358369
suggested = "_".join(filename_parts[:3])
370+
if search_upgrades:
371+
# Extract base prefix (e.g., IMDB_SERVER20_)
372+
suggested_base = "_".join(filename_parts[:2]) + '_'
373+
# For 3-digit revisions, return prefix without last digit to find closest revision
374+
# Example: IMDB_SERVER20_077 → IMDB_SERVER20_07 catches 070-079 within same SPS 07.
375+
suggested_next = _increment_last_digits(suggested)
376+
if len(filename_parts) > 2 and len(filename_parts[2]) >= 3:
377+
suggested_next = "_".join(filename_parts[:2]) + '_' + filename_parts[2][:2]
378+
return suggested, suggested_next, suggested_base
359379
return suggested, None, None
360380

361381
# Example: IMDB_CLIENT20_021_31-80002082.SAR returns (IMDB_CLIENT20_021, IMDB_CLIENT20_022, None)
382+
# With search_upgrades: (IMDB_CLIENT20_021, IMDB_CLIENT20_022, IMDB_CLIENT20_)
362383
elif filename_base.startswith('IMDB_CLIENT'):
363384
if len(filename_parts) > 2:
364385
filename_parts[2] = filename_parts[2].rstrip('Pp')
365386
suggested = "_".join(filename_parts[:3])
387+
if search_upgrades:
388+
suggested_base = "_".join(filename_parts[:2]) + '_'
389+
return suggested, _increment_last_digits(suggested), suggested_base
366390
return suggested, _increment_last_digits(suggested), None
367391

368392
# Example: SAPEXE_100-80005374.SAR returns (SAPEXE_100, SAPEXE_101, None)
393+
# With search_upgrades: (SAPEXE_100, SAPEXE_101, SAPEXE_)
369394
elif filename_base.startswith('SAPEXE'):
370395
suggested = filename_main
396+
if search_upgrades:
397+
return suggested, _increment_last_digits(suggested), 'SAPEXE_'
371398
return suggested, _increment_last_digits(suggested), None
372399

373400
# Example: SAPHANACOCKPIT02_0-70002300.SAR returns (SAPHANACOCKPIT02, SAPHANACOCKPIT03, None)

plugins/modules/software_center_download.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@
8282
required: false
8383
default: false
8484
type: bool
85+
search_upgrades:
86+
description:
87+
- Enable broader search to find any available version in the product family.
88+
- Only applies when C(search_alternatives) is enabled.
89+
- Useful when specific versions are removed from SAP Software Center (e.g., old HANA revisions).
90+
- "Example: searching for C(IMDB_SERVER20_067_4) may find C(IMDB_SERVER20_080_0) if 067 is no longer available."
91+
- Typically combined with C(deduplicate='last') to get the newest available version.
92+
required: false
93+
default: false
94+
type: bool
8595
dry_run:
8696
description:
8797
- Check availability of SAP Software without downloading.
@@ -146,6 +156,16 @@
146156
deduplicate: "last"
147157
validate_checksum: true
148158
159+
- name: Download latest available HANA revision when specific revision is not available
160+
community.sap_launchpad.software_center_download:
161+
suser_id: "Enter SAP S-User ID"
162+
suser_password: "Enter SAP S-User Password"
163+
search_query: 'IMDB_SERVER20_067_4-80002046.SAR'
164+
dest: "/sap_media"
165+
search_alternatives: true
166+
search_upgrades: true
167+
deduplicate: "last"
168+
149169
- name: Dry run to check file availability without downloading
150170
community.sap_launchpad.software_center_download:
151171
suser_id: "Enter SAP S-User ID"
@@ -168,10 +188,26 @@
168188
returned: on success or failure after finding a file
169189
type: str
170190
sample: "SAPCAR_1324-80000936.EXE"
191+
original_query:
192+
description: The original search query or download filename that was requested.
193+
returned: always
194+
type: str
195+
sample: "IMDB_SERVER20_077_0-80002031.SAR"
171196
alternative:
172197
description: A boolean indicating if an alternative file was downloaded instead of the one from the original search query.
173198
returned: on success
174199
type: bool
200+
search_method:
201+
description: Indicates how the file was found in the search process.
202+
returned: on success
203+
type: str
204+
sample: "alternative_base"
205+
choices:
206+
- exact: Exact match found for the search query
207+
- direct_link: File downloaded using direct download link (no search performed)
208+
- alternative_default: Alternative found using same version prefix
209+
- alternative_increment: Alternative found using incremented version
210+
- alternative_base: Alternative found using broader base prefix (search_upgrades enabled)
175211
changed:
176212
description: A boolean indicating if a file was downloaded or changed on the remote host.
177213
returned: always
@@ -200,6 +236,7 @@ def run_module():
200236
dry_run=dict(type='bool', required=False, default=False),
201237
deduplicate=dict(type='str', required=False, default='last', choices=['first', 'last', '']),
202238
search_alternatives=dict(type='bool', required=False, default=False),
239+
search_upgrades=dict(type='bool', required=False, default=False),
203240
validate_checksum=dict(type='bool', required=False, default=False)
204241
)
205242

0 commit comments

Comments
 (0)