diff --git a/redfish_interop_validator/RedfishInteropValidator.py b/redfish_interop_validator/RedfishInteropValidator.py index 05919a2..b7d1962 100644 --- a/redfish_interop_validator/RedfishInteropValidator.py +++ b/redfish_interop_validator/RedfishInteropValidator.py @@ -284,7 +284,7 @@ def main(argslist=None, configfile=None): import redfish_interop_validator.tohtml as tohtml - html_str = tohtml.renderHtml(results, tool_version, start_tick, now_tick, currentService) + html_str = tohtml.renderHtml(results, final_counts, tool_version, start_tick, now_tick, currentService) lastResultsPage = datetime.strftime(start_tick, os.path.join(logpath, "InteropHtmlLog_%m_%d_%Y_%H%M%S.html")) @@ -295,7 +295,7 @@ def main(argslist=None, configfile=None): 'Pass: {}'.format(final_counts['pass']), 'Fail: {}'.format(final_counts['error']), 'Warning: {}'.format(final_counts['warning']), - 'Not Tested: {}'.format(final_counts['nottested']), + 'Not Tested: {}'.format(final_counts['not_tested']), ])) success = final_counts['error'] == 0 diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index 8ba7857..066212e 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -152,7 +152,7 @@ def parseProfileInclude(target_name, target_profile_info, directories, online): def getProfiles(profile, directories, chain=None, online=False): profile_includes, required_by_resource = [], [] - + # Prevent cyclical imports when possible profile_name = profile.get('ProfileName') if chain is None: @@ -174,12 +174,11 @@ def getProfiles(profile, directories, chain=None, online=False): inner_includes, inner_reqs = getProfiles(profile_data, directories, chain) profile_includes.extend(inner_includes) required_by_resource.extend(inner_reqs) - + # Process all RequiredResourceProfile by modifying profiles profile_resources = profile.get('Resources', {}) - for resource_name, resource in profile_resources.items(): - # Modify just the resource or its UseCases. Should not have concurrent UseCases and RequiredResourceProfile in Resource + # Modify just the resource or its UseCases. Should not have concurrent UseCases and RequiredResourceProfile in Resource if 'UseCases' not in resource: modifying_objects = [resource] else: diff --git a/redfish_interop_validator/tohtml.py b/redfish_interop_validator/tohtml.py index dccd004..f325afb 100644 --- a/redfish_interop_validator/tohtml.py +++ b/redfish_interop_validator/tohtml.py @@ -77,7 +77,7 @@ def applyInfoSuccessColor(num, entry): return tag.div(entry, attr=style) -def renderHtml(results, tool_version, startTick, nowTick, service): +def renderHtml(results, finalCounts, tool_version, startTick, nowTick, service): # Render html config = service.config config_str = ', '.join(sorted(list(config.keys() - set(['systeminfo', 'targetip', 'password', 'description'])))) @@ -142,15 +142,18 @@ def renderHtml(results, tool_version, startTick, nowTick, service): for k, my_result in results.items(): for msg in my_result['messages']: - if msg.result in [testResultEnum.PASS]: + if msg.result == testResultEnum.PASS: summary['pass'] += 1 - if msg.result in [testResultEnum.NOT_TESTED]: + elif msg.result == testResultEnum.FAIL: + summary['error'] += 1 + elif msg.result == testResultEnum.WARN: + summary['warning'] += 1 + elif msg.result == testResultEnum.NOT_TESTED: summary['not_tested'] += 1 + # to avoid double counting, only count if record doesn't have a 'result' attribute for record in my_result['records']: - if record.levelname.lower() in ['error', 'warning']: + if not record.result and record.levelname.lower() in ['error', 'warning']: summary[record.levelname.lower()] += 1 - if record.result: - summary[record.result] += 1 important_block = tag.div('Results Summary') important_block += tag.div(", ".join([ @@ -161,6 +164,16 @@ def renderHtml(results, tool_version, startTick, nowTick, service): ])) htmlStrBodyHeader += tag.tr(tag.td(important_block, 'class="center"')) + # Filter non-zero counts and split into two columns + non_zero_items = [(k, v) for k, v in sorted(finalCounts.items()) if v != 0] + infos_left = dict(non_zero_items[::2]) # every other item starting at 0 + infos_right = dict(non_zero_items[1::2]) # every other item starting at 1 + + htmlStrCounts = (tag.div(infoBlock(infos_left), 'class=\'column log\'') + + tag.div(infoBlock(infos_right), 'class=\'column log\'')) + + htmlStrBodyHeader += tag.tr(tag.td(htmlStrCounts)) + infos = {x: config[x] for x in config if x not in ['systeminfo', 'ip', 'password', 'description']} infos_left, infos_right = dict(), dict() for key in sorted(infos.keys()): @@ -232,10 +245,16 @@ def renderHtml(results, tool_version, startTick, nowTick, service): my_summary = Counter() for msg in my_result['messages']: - if msg.result in [testResultEnum.PASS]: + if msg.result == testResultEnum.PASS: my_summary['pass'] += 1 - if msg.result in [testResultEnum.NOT_TESTED]: + elif msg.result == testResultEnum.NOT_TESTED: my_summary['not_tested'] += 1 + elif msg.result == testResultEnum.FAIL: + my_summary['fail'] += 1 + elif msg.result == testResultEnum.WARN: + my_summary['warn'] += 1 + elif msg.result in [testResultEnum.NOPASS, testResultEnum.OK, testResultEnum.NA]: + my_summary[msg.result.value.lower()] += 1 for record in my_result['records']: if record.levelname.lower() in ['error', 'warning']: diff --git a/redfish_interop_validator/validateResource.py b/redfish_interop_validator/validateResource.py index 23cf8c4..9d98c5e 100644 --- a/redfish_interop_validator/validateResource.py +++ b/redfish_interop_validator/validateResource.py @@ -5,6 +5,8 @@ import logging import re from io import StringIO +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading import redfish_interop_validator.traverseInterop as traverseInterop import redfish_interop_validator.interop as interop @@ -208,7 +210,7 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non links, limited_links = links if links else ({}, {}) for skipped_link in limited_links: - allLinks.add(limited_links[skipped_link]) + allLinks.add(limited_links[skipped_link].rstrip('/')) if resource_obj: SchemaType = getType(resource_obj.jsondata.get('@odata.type', 'NoType')) @@ -231,14 +233,20 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non message_list.append(msg) currentLinks = [(link, links[link], resource_obj) for link in links] + # Get max_workers from config with default of 100 + max_workers = traverseInterop.config.get('max_workers', 50) + results_lock = threading.Lock() + # todo : churning a lot of links, causing possible slowdown even with set checks while len(currentLinks) > 0: newLinks = list() - for linkName, link, parent in currentLinks: + # Filter links to process (skip already visited, fragments, etc.) + links_to_process = [] + for linkName, link, parent in currentLinks: if link is None or link.rstrip('/') in allLinks: continue - + if '#' in link: # NOTE: Skips referenced Links (using pound signs), this program currently only works with direct links continue @@ -247,51 +255,97 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non refLinks.append((linkName, link, parent)) continue - # NOTE: unable to determine autoexpanded resources without Schema - else: - linkSuccess, linkResults, inner_links, linkobj = \ - validateSingleURI(link, profile, linkName, parent=parent) - allLinks.add(link.rstrip('/')) + links_to_process.append((linkName, link, parent)) - results.update(linkResults) - - if not linkSuccess: - continue - - inner_links, inner_limited_links = inner_links - - for skipped_link in inner_limited_links: - allLinks.add(inner_limited_links[skipped_link]) - - innerLinksTuple = [(link, inner_links[link], linkobj) for link in inner_links] - newLinks.extend(innerLinksTuple) - SchemaType = getType(linkobj.jsondata.get('@odata.type', 'NoType')) - - subordinate_tree = [] - - current_parent = linkobj.parent - while current_parent: - parentType = getType(current_parent.jsondata.get('@odata.type', 'NoType')) - subordinate_tree.append(parentType) - current_parent = current_parent.parent - - # Search for UseCase.USECASENAME - usecases_found = [msg.name.split('.')[-1] for msg in linkResults[linkName]['messages'] if 'UseCase' == msg.name.split('.')[0]] + # Skip parallel processing if no links to process + if not links_to_process: + if refLinks is not currentLinks and len(newLinks) == 0 and len(refLinks) > 0: + currentLinks = refLinks + else: + currentLinks = newLinks + continue - if resource_stats.get(SchemaType) is None: - resource_stats[SchemaType] = { - "Exists": True, - "Writeable": False, - "URIsFound": [link.rstrip('/')], - "SubordinateTo": set([tuple(reversed(subordinate_tree))]), - "UseCasesFound": set(usecases_found), + # Process links in parallel using ThreadPoolExecutor + try: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all validation tasks + future_to_link = { + executor.submit(validateSingleURI, link, profile, linkName, parent=parent): (linkName, link, parent) + for linkName, link, parent in links_to_process } - else: - resource_stats[SchemaType]['Exists'] = True - resource_stats[SchemaType]['URIsFound'].append(link.rstrip('/')) - resource_stats[SchemaType]['SubordinateTo'].add(tuple(reversed(subordinate_tree))) - resource_stats[SchemaType]['UseCasesFound'] = resource_stats[SchemaType]['UseCasesFound'].union(usecases_found) + + # Process results as they complete + for future in as_completed(future_to_link): + linkName, link, parent = future_to_link[future] + + try: + linkSuccess, linkResults, inner_links, linkobj = future.result() + except KeyboardInterrupt: + # Re-raise keyboard interrupt for graceful shutdown + raise + except traverseInterop.AuthenticationError as e: + my_logger.warning(f'Authentication error for {link}: {repr(e)}') + # Mark link as visited even on failure to avoid retry loops + with results_lock: + allLinks.add(link.rstrip('/')) + continue + except Exception as e: + my_logger.error(f'Exception during parallel validation of {link}: {repr(e)}') + # Mark link as visited even on failure to avoid retry loops + with results_lock: + allLinks.add(link.rstrip('/')) + continue + + # Thread-safe updates to shared state + with results_lock: + allLinks.add(link.rstrip('/')) + results.update(linkResults) + + if not linkSuccess: + continue + + inner_links, inner_limited_links = inner_links + + with results_lock: + for skipped_link in inner_limited_links: + allLinks.add(inner_limited_links[skipped_link].rstrip('/')) + + innerLinksTuple = [(link, inner_links[link], linkobj) for link in inner_links] + + # Thread-safe update to newLinks + with results_lock: + newLinks.extend(innerLinksTuple) + + SchemaType = getType(linkobj.jsondata.get('@odata.type', 'NoType')) + + subordinate_tree = [] + + current_parent = linkobj.parent + while current_parent: + parentType = getType(current_parent.jsondata.get('@odata.type', 'NoType')) + subordinate_tree.append(parentType) + current_parent = current_parent.parent + + usecases_found = [msg.name.split('.')[-1] for msg in linkResults[linkName]['messages'] if 'UseCase' == msg.name.split('.')[0]] + + with results_lock: + if resource_stats.get(SchemaType) is None: + resource_stats[SchemaType] = { + "Exists": True, + "Writeable": False, + "URIsFound": [link.rstrip('/')], + "SubordinateTo": set([tuple(reversed(subordinate_tree))]), + "UseCasesFound": set(usecases_found), + } + else: + resource_stats[SchemaType]['Exists'] = True + resource_stats[SchemaType]['URIsFound'].append(link.rstrip('/')) + resource_stats[SchemaType]['SubordinateTo'].add(tuple(reversed(subordinate_tree))) + resource_stats[SchemaType]['UseCasesFound'] = resource_stats[SchemaType]['UseCasesFound'].union(usecases_found) + except KeyboardInterrupt: + # Re-raise keyboard interrupt for graceful shutdown + raise if refLinks is not currentLinks and len(newLinks) == 0 and len(refLinks) > 0: currentLinks = refLinks