diff --git a/README.md b/README.md index 0ef4df63..056d6e25 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ The Node Scraper CLI can be used to run Node Scraper plugins on a target system. options are available: ```sh -usage: node-scraper [-h] [--sys-name STRING] [--sys-location {LOCAL,REMOTE}] [--sys-interaction-level {PASSIVE,INTERACTIVE,DISRUPTIVE}] [--sys-sku STRING] [--sys-platform STRING] [--plugin-configs [STRING ...]] - [--system-config STRING] [--connection-config STRING] [--log-path STRING] [--log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}] +usage: node-scraper [-h] [--sys-name STRING] [--sys-location {LOCAL,REMOTE}] + [--sys-interaction-level {PASSIVE,INTERACTIVE,DISRUPTIVE}] [--sys-sku STRING] [--sys-platform STRING] + [--plugin-configs [STRING ...]] [--system-config STRING] [--connection-config STRING] [--log-path STRING] + [--log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}] [--gen-reference-config] {run-plugins,describe,gen-plugin-config} ... node scraper CLI @@ -32,16 +34,18 @@ positional arguments: options: -h, --help show this help message and exit - --sys-name STRING System name (default: TheraC55) + --sys-name STRING System name (default: ) --sys-location {LOCAL,REMOTE} Location of target system (default: LOCAL) --sys-interaction-level {PASSIVE,INTERACTIVE,DISRUPTIVE} - Specify system interaction level, used to determine the type of actions that plugins can perform (default: INTERACTIVE) + Specify system interaction level, used to determine the type of actions that plugins can perform (default: + INTERACTIVE) --sys-sku STRING Manually specify SKU of system (default: None) --sys-platform STRING Specify system platform (default: None) --plugin-configs [STRING ...] - built-in config names or paths to plugin config JSONs. Available built-in configs: NodeStatus (default: None) + built-in config names or paths to plugin config JSONs. Available built-in configs: NodeStatus (default: + None) --system-config STRING Path to system config json (default: None) --connection-config STRING @@ -49,103 +53,41 @@ options: --log-path STRING Specifies local path for node scraper logs, use 'None' to disable logging (default: .) --log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET} Change python log level (default: INFO) + --gen-reference-config + Generate reference config. File will be written to ./reference_config.json. (default: False) ``` -The plugins to run can be specified in two ways, using a plugin JSON config file or using the +### Subcommmands + +Plugins to run can be specified in two ways, using a plugin JSON config file or using the 'run-plugins' sub command. These two options are not mutually exclusive and can be used together. ---- -### Describing Built-in Configs and Plugins +1. **'describe' subcommand** You can use the `describe` subcommand to display details about built-in configs or plugins. - -#### List all built-in configs: +List all built-in configs: ```sh node-scraper describe config ``` -#### Show details for a specific built-in config: +Show details for a specific built-in config ```sh node-scraper describe config ``` -#### List all available plugins: +List all available plugins** ```sh node-scraper describe plugin ``` -#### Show details for a specific plugin: +Show details for a specific plugin ```sh node-scraper describe plugin ``` ---- - -### Plugin Configs -A plugin JSON config should follow the structure of the plugin config model defined here. -The globals field is a dictionary of global key-value pairs; values in globals will be passed to -any plugin that supports the corresponding key. The plugins field should be a dictionary mapping -plugin names to sub-dictionaries of plugin arguments. Lastly, the result_collators attribute is -used to define result collator classes that will be run on the plugin results. By default, the CLI -adds the TableSummary result collator, which prints a summary of each plugin’s results in a -tabular format to the console. - -```json -{ - "globals_args": {}, - "plugins": { - "BiosPlugin": { - "analysis_args": { - "exp_bios_version": "TestBios123" - } - }, - "RocmPlugin": { - "analysis_args": { - "exp_rocm_version": "TestRocm123" - } - } - } -} -``` - -### 'gen-plugin-config' sub command -The 'gen-plugin-config' sub command can be used to generate a plugin config JSON file for a plugin -or list of plugins that can then be customized. Plugin arguments which have default values will be -prepopulated in the JSON file, arguments without default values will have a value of 'null'. - -#### 'gen-plugin-config' Examples - -Generate a config for the DmesgPlugin: -```sh -node-scraper gen-plugin-config --plugins DmesgPlugin -``` - -This would produce the following config: - -```json -{ - "global_args": {}, - "plugins": { - "DmesgPlugin": { - "collection": true, - "analysis": true, - "system_interaction_level": "INTERACTIVE", - "data": null, - "analysis_args": { - "analysis_range_start": null, - "analysis_range_end": null, - "check_unknown_dmesg_errors": true, - "exclude_category": null - } - } - }, - "result_collators": {} -} -``` - -### 'run-plugins' sub command +2. **'run-plugins' sub command** The plugins to run and their associated arguments can also be specified directly on the CLI using the 'run-plugins' sub-command. Using this sub-command you can specify a plugin name followed by the arguments for that particular plugin. Multiple plugins can be specified at once. @@ -167,7 +109,7 @@ options: ``` -#### 'run-plugins' Examples +Examples Run a single plugin ```sh @@ -191,8 +133,69 @@ Use plugin configs and 'run-plugins' node-scraper run-plugins BiosPlugin ``` +3. **'gen-plugin-config' sub command** +The 'gen-plugin-config' sub command can be used to generate a plugin config JSON file for a plugin +or list of plugins that can then be customized. Plugin arguments which have default values will be +prepopulated in the JSON file, arguments without default values will have a value of 'null'. + +Examples + +Generate a config for the DmesgPlugin: +```sh +node-scraper gen-plugin-config --plugins DmesgPlugin +``` + +This would produce the following config: + +```json +{ + "global_args": {}, + "plugins": { + "DmesgPlugin": { + "collection": true, + "analysis": true, + "system_interaction_level": "INTERACTIVE", + "data": null, + "analysis_args": { + "analysis_range_start": null, + "analysis_range_end": null, + "check_unknown_dmesg_errors": true, + "exclude_category": null + } + } + }, + "result_collators": {} +} +``` + +### Plugin Configs +A plugin JSON config should follow the structure of the plugin config model defined here. +The globals field is a dictionary of global key-value pairs; values in globals will be passed to +any plugin that supports the corresponding key. The plugins field should be a dictionary mapping +plugin names to sub-dictionaries of plugin arguments. Lastly, the result_collators attribute is +used to define result collator classes that will be run on the plugin results. By default, the CLI +adds the TableSummary result collator, which prints a summary of each plugin’s results in a +tabular format to the console. + +```json +{ + "globals_args": {}, + "plugins": { + "BiosPlugin": { + "analysis_args": { + "exp_bios_version": "TestBios123" + } + }, + "RocmPlugin": { + "analysis_args": { + "exp_rocm_version": "TestRocm123" + } + } + } +} +``` -### '--plugin-configs' example +1. **'--plugin-configs' command** A plugin config can be used to compare the system data against the config specifications: ```sh node-scraper --plugin-configs plugin_config.json @@ -249,3 +252,42 @@ Here is an example of a comprehensive plugin config that specifies analyzer args "desc": "My golden config" } ``` + +2. **'gen-reference-config' command** +This command can be used generate a reference config that is populated with current system +configurations. The plugins that use analyzer args, where applied, will be populated with system +data. +Sample command: +```sh +node-scraper --gen-reference-config run-plugins BiosPlugin OsPlugin + +``` +This will generate the following config: +```json +{ + "global_args": {}, + "plugins": { + "BiosPlugin": { + "analysis_args": { + "exp_bios_version": [ + "M17" + ], + "regex_match": false + } + }, + "OsPlugin": { + "analysis_args": { + "exp_os": [ + "8.10" + ], + "exact_match": true + } + } + }, + "result_collators": {} +``` +This can be later used on a different platform for comparison, using the steps at #2: +```sh +node-scraper --plugin-configs reference_config.json + +``` diff --git a/nodescraper/cli/cli.py b/nodescraper/cli/cli.py index 0528b892..2caf413a 100644 --- a/nodescraper/cli/cli.py +++ b/nodescraper/cli/cli.py @@ -34,15 +34,21 @@ from nodescraper.cli.constants import DEFAULT_CONFIG, META_VAR_MAP from nodescraper.cli.dynamicparserbuilder import DynamicParserBuilder +from nodescraper.cli.helper import ( + generate_reference_config, + get_plugin_configs, + get_system_info, + log_system_info, + parse_describe, + parse_gen_plugin_config, +) from nodescraper.cli.inputargtypes import ModelArgHandler, json_arg, log_path_arg -from nodescraper.configbuilder import ConfigBuilder from nodescraper.configregistry import ConfigRegistry from nodescraper.constants import DEFAULT_LOGGER from nodescraper.enums import ExecutionStatus, SystemInteractionLevel, SystemLocation -from nodescraper.models import PluginConfig, SystemInfo +from nodescraper.models import SystemInfo from nodescraper.pluginexecutor import PluginExecutor from nodescraper.pluginregistry import PluginRegistry -from nodescraper.resultcollators.tablesummary import TableSummary def build_parser( @@ -138,6 +144,13 @@ def build_parser( help="Change python log level", ) + parser.add_argument( + "--gen-reference-config", + dest="reference_config", + action="store_true", + help="Generate reference config. File will be written to ./reference_config.json.", + ) + subparsers = parser.add_subparsers(dest="subcmd", help="Subcommands") run_plugin_parser = subparsers.add_parser( @@ -249,111 +262,6 @@ def setup_logger(log_level: str = "INFO", log_path: str | None = None) -> loggin return logger -def get_system_info(args: argparse.Namespace) -> SystemInfo: - """build system info object using args - - Args: - args (argparse.Namespace): parsed args - - Raises: - argparse.ArgumentTypeError: if system location arg is invalid - - Returns: - SystemInfo: system info instance - """ - - if args.system_config: - system_info = args.system_config - else: - system_info = SystemInfo() - - if args.sys_name: - system_info.name = args.sys_name - - if args.sys_sku: - system_info.sku = args.sys_sku - - if args.sys_platform: - system_info.platform = args.sys_platform - - if args.sys_location: - try: - location = getattr(SystemLocation, args.sys_location) - except Exception as e: - raise argparse.ArgumentTypeError("Invalid input for system location") from e - - system_info.location = location - - return system_info - - -def get_plugin_configs( - plugin_config_input: list[str], - system_interaction_level: SystemInteractionLevel, - built_in_configs: dict[str, PluginConfig], - parsed_plugin_args: dict[str, argparse.Namespace], - plugin_subparser_map: dict[str, tuple[argparse.ArgumentParser, dict]], -) -> list[PluginConfig]: - """Build list of plugin configs based on input args - - Args: - plugin_config_input (list[str]): list of plugin config inputs, can be paths to JSON files or built-in config names - system_interaction_level (SystemInteractionLevel): system interaction level, used to determine the type of actions that plugins can perform - built_in_configs (dict[str, PluginConfig]): built-in plugin configs, mapping from config name to PluginConfig instance - parsed_plugin_args (dict[str, argparse.Namespace]): parsed plugin arguments, mapping from plugin name to parsed args - plugin_subparser_map (dict[str, tuple[argparse.ArgumentParser, dict]]): plugin subparser map, mapping from plugin name to tuple of parser and model type map - - Raises: - argparse.ArgumentTypeError: if system interaction level is invalid - argparse.ArgumentTypeError: if no plugin config found for a given input - - Returns: - list[PluginConfig]: list of PluginConfig instances based on input args - """ - try: - system_interaction_level = getattr(SystemInteractionLevel, system_interaction_level) - except Exception as e: - raise argparse.ArgumentTypeError("Invalid input for system interaction level") from e - - base_config = PluginConfig(result_collators={str(TableSummary.__name__): {}}) - - base_config.global_args["system_interaction_level"] = system_interaction_level - - plugin_configs = [base_config] - - if plugin_config_input: - for config in plugin_config_input: - if os.path.exists(config): - plugin_configs.append(ModelArgHandler(PluginConfig).process_file_arg(config)) - elif config in built_in_configs: - plugin_configs.append(built_in_configs[config]) - else: - raise argparse.ArgumentTypeError(f"No plugin config found for: {config}") - - if parsed_plugin_args: - plugin_input_config = PluginConfig() - - for plugin, plugin_args in parsed_plugin_args.items(): - config = {} - model_type_map = plugin_subparser_map[plugin][1] - for arg, val in vars(plugin_args).items(): - if val is None: - continue - if arg in model_type_map: - model = model_type_map[arg] - if model in config: - config[model][arg] = val - else: - config[model] = {arg: val} - else: - config[arg] = val - plugin_input_config.plugins[plugin] = config - - plugin_configs.append(plugin_input_config) - - return plugin_configs - - def process_args( raw_arg_input: list[str], plugin_names: list[str] ) -> tuple[list[str], dict[str, list[str]]]: @@ -393,144 +301,6 @@ def process_args( return (top_level_args, plugin_arg_map) -def build_config( - config_reg: ConfigRegistry, - plugin_reg: PluginRegistry, - logger: logging.Logger, - plugins: Optional[list[str]] = None, - built_in_configs: Optional[list[str]] = None, -) -> PluginConfig: - """build a plugin config - - Args: - config_reg (ConfigRegistry): config registry instance - plugin_reg (PluginRegistry): plugin registry instance - logger (logging.Logger): logger instance - plugins (Optional[list[str]], optional): list of plugin names to include. Defaults to None. - built_in_configs (Optional[list[str]], optional): list of built in config names to include. Defaults to None. - - Returns: - PluginConfig: plugin config obf - """ - configs = [] - if plugins: - logger.info("Building config for plugins: %s", plugins) - config_builder = ConfigBuilder(plugin_registry=plugin_reg) - configs.append(config_builder.gen_config(plugins)) - - if built_in_configs: - logger.info("Retrieving built in configs: %s", built_in_configs) - for config in built_in_configs: - if config not in config_reg.configs: - logger.warning("No built in config found for name: %s", config) - else: - configs.append(config_reg.configs[config]) - - config = PluginExecutor.merge_configs(configs) - return config - - -def parse_describe( - parsed_args: argparse.Namespace, - plugin_reg: PluginRegistry, - config_reg: ConfigRegistry, - logger: logging.Logger, -): - """parse 'describe' cmd line argument - - Args: - parsed_args (argparse.Namespace): parsed cmd line arguments - plugin_reg (PluginRegistry): plugin registry instance - config_reg (ConfigRegistry): config registry instance - logger (logging.Logger): logger instance - """ - if not parsed_args.name: - if parsed_args.type == "config": - print("Available built-in configs:") # noqa: T201 - for name in config_reg.configs: - print(f" {name}") # noqa: T201 - elif parsed_args.type == "plugin": - print("Available plugins:") # noqa: T201 - for name in plugin_reg.plugins: - print(f" {name}") # noqa: T201 - print(f"\nUsage: describe {parsed_args.type} ") # noqa: T201 - sys.exit(0) - - if parsed_args.type == "config": - if parsed_args.name not in config_reg.configs: - logger.error("No config found for name: %s", parsed_args.name) - sys.exit(1) - config_model = config_reg.configs[parsed_args.name] - print(f"Config Name: {parsed_args.name}") # noqa: T201 - print(f"Description: {getattr(config_model, 'desc', '')}") # noqa: T201 - print("Plugins:") # noqa: T201 - for plugin in getattr(config_model, "plugins", []): - print(f"\t{plugin}") # noqa: T201 - - elif parsed_args.type == "plugin": - if parsed_args.name not in plugin_reg.plugins: - logger.error("No plugin found for name: %s", parsed_args.name) - sys.exit(1) - plugin_class = plugin_reg.plugins[parsed_args.name] - print(f"Plugin Name: {parsed_args.name}") # noqa: T201 - print(f"Description: {getattr(plugin_class, '__doc__', '')}") # noqa: T201 - - sys.exit(0) - - -def parse_gen_plugin_config( - parsed_args: argparse.Namespace, - plugin_reg: PluginRegistry, - config_reg: ConfigRegistry, - logger: logging.Logger, -): - """parse 'gen_plugin_config' cmd line argument - - Args: - parsed_args (argparse.Namespace): parsed cmd line arguments - plugin_reg (PluginRegistry): plugin registry instance - config_reg (ConfigRegistry): config registry instance - logger (logging.Logger): logger instance - """ - try: - config = build_config( - config_reg, plugin_reg, logger, parsed_args.plugins, parsed_args.built_in_configs - ) - - config.name = parsed_args.config_name.split(".")[0] - config.desc = "Auto generated config" - output_path = os.path.join(parsed_args.output_path, parsed_args.config_name) - with open(output_path, "w", encoding="utf-8") as out_file: - out_file.write(config.model_dump_json(indent=2)) - - logger.info("Config saved to: %s", output_path) - sys.exit(0) - except Exception: - logger.exception("Exception when building config") - sys.exit(1) - - -def log_system_info(log_path: str | None, system_info: SystemInfo, logger: logging.Logger): - """dump system info object to json log - - Args: - log_path (str): path to log folder - system_info (SystemInfo): system object instance - """ - if log_path: - try: - with open( - os.path.join(log_path, "system_info.json"), "w", encoding="utf-8" - ) as log_file: - json.dump( - system_info.model_dump(mode="json", exclude_none=True), - log_file, - indent=2, - ) - except Exception as exp: - logger.error(exp) - - def main(arg_input: Optional[list[str]] = None): """Main entry point for the CLI @@ -606,6 +376,21 @@ def main(arg_input: Optional[list[str]] = None): try: results = plugin_executor.run_queue() + + if parsed_args.reference_config: + ref_config = generate_reference_config(results, plugin_reg, logger) + path = os.path.join(os.getcwd(), "reference_config.json") + try: + with open(path, "w") as f: + json.dump( + ref_config.model_dump(mode="json", exclude_none=True), + f, + indent=2, + ) + logger.info("Reference config written to: %s", path) + except Exception as exp: + logger.error(exp) + if any(result.status > ExecutionStatus.WARNING for result in results): sys.exit(1) else: diff --git a/nodescraper/cli/helper.py b/nodescraper/cli/helper.py new file mode 100644 index 00000000..fa5143fe --- /dev/null +++ b/nodescraper/cli/helper.py @@ -0,0 +1,333 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import argparse +import json +import logging +import os +import sys +from typing import Optional + +from nodescraper.cli.inputargtypes import ModelArgHandler +from nodescraper.configbuilder import ConfigBuilder +from nodescraper.configregistry import ConfigRegistry +from nodescraper.enums import ExecutionStatus, SystemInteractionLevel, SystemLocation +from nodescraper.models import PluginConfig, PluginResult, SystemInfo +from nodescraper.pluginexecutor import PluginExecutor +from nodescraper.pluginregistry import PluginRegistry +from nodescraper.resultcollators.tablesummary import TableSummary + + +def get_system_info(args: argparse.Namespace) -> SystemInfo: + """build system info object using args + + Args: + args (argparse.Namespace): parsed args + + Raises: + argparse.ArgumentTypeError: if system location arg is invalid + + Returns: + SystemInfo: system info instance + """ + + if args.system_config: + system_info = args.system_config + else: + system_info = SystemInfo() + + if args.sys_name: + system_info.name = args.sys_name + + if args.sys_sku: + system_info.sku = args.sys_sku + + if args.sys_platform: + system_info.platform = args.sys_platform + + if args.sys_location: + try: + location = getattr(SystemLocation, args.sys_location) + except Exception as e: + raise argparse.ArgumentTypeError("Invalid input for system location") from e + + system_info.location = location + + return system_info + + +def get_plugin_configs( + plugin_config_input: list[str], + system_interaction_level: SystemInteractionLevel, + built_in_configs: dict[str, PluginConfig], + parsed_plugin_args: dict[str, argparse.Namespace], + plugin_subparser_map: dict[str, tuple[argparse.ArgumentParser, dict]], +) -> list[PluginConfig]: + """Build list of plugin configs based on input args + + Args: + plugin_config_input (list[str]): list of plugin config inputs, can be paths to JSON files or built-in config names + system_interaction_level (SystemInteractionLevel): system interaction level, used to determine the type of actions that plugins can perform + built_in_configs (dict[str, PluginConfig]): built-in plugin configs, mapping from config name to PluginConfig instance + parsed_plugin_args (dict[str, argparse.Namespace]): parsed plugin arguments, mapping from plugin name to parsed args + plugin_subparser_map (dict[str, tuple[argparse.ArgumentParser, dict]]): plugin subparser map, mapping from plugin name to tuple of parser and model type map + + Raises: + argparse.ArgumentTypeError: if system interaction level is invalid + argparse.ArgumentTypeError: if no plugin config found for a given input + + Returns: + list[PluginConfig]: list of PluginConfig instances based on input args + """ + try: + system_interaction_level = getattr(SystemInteractionLevel, system_interaction_level) + except Exception as e: + raise argparse.ArgumentTypeError("Invalid input for system interaction level") from e + + base_config = PluginConfig(result_collators={str(TableSummary.__name__): {}}) + + base_config.global_args["system_interaction_level"] = system_interaction_level + + plugin_configs = [base_config] + + if plugin_config_input: + for config in plugin_config_input: + if os.path.exists(config): + plugin_configs.append(ModelArgHandler(PluginConfig).process_file_arg(config)) + elif config in built_in_configs: + plugin_configs.append(built_in_configs[config]) + else: + raise argparse.ArgumentTypeError(f"No plugin config found for: {config}") + + if parsed_plugin_args: + plugin_input_config = PluginConfig() + + for plugin, plugin_args in parsed_plugin_args.items(): + config = {} + model_type_map = plugin_subparser_map[plugin][1] + for arg, val in vars(plugin_args).items(): + if val is None: + continue + if arg in model_type_map: + model = model_type_map[arg] + if model in config: + config[model][arg] = val + else: + config[model] = {arg: val} + else: + config[arg] = val + plugin_input_config.plugins[plugin] = config + + plugin_configs.append(plugin_input_config) + + return plugin_configs + + +def build_config( + config_reg: ConfigRegistry, + plugin_reg: PluginRegistry, + logger: logging.Logger, + plugins: Optional[list[str]] = None, + built_in_configs: Optional[list[str]] = None, +) -> PluginConfig: + """build a plugin config + + Args: + config_reg (ConfigRegistry): config registry instance + plugin_reg (PluginRegistry): plugin registry instance + logger (logging.Logger): logger instance + plugins (Optional[list[str]], optional): list of plugin names to include. Defaults to None. + built_in_configs (Optional[list[str]], optional): list of built in config names to include. Defaults to None. + + Returns: + PluginConfig: plugin config obf + """ + configs = [] + if plugins: + logger.info("Building config for plugins: %s", plugins) + config_builder = ConfigBuilder(plugin_registry=plugin_reg) + configs.append(config_builder.gen_config(plugins)) + + if built_in_configs: + logger.info("Retrieving built in configs: %s", built_in_configs) + for config in built_in_configs: + if config not in config_reg.configs: + logger.warning("No built in config found for name: %s", config) + else: + configs.append(config_reg.configs[config]) + + config = PluginExecutor.merge_configs(configs) + return config + + +def parse_describe( + parsed_args: argparse.Namespace, + plugin_reg: PluginRegistry, + config_reg: ConfigRegistry, + logger: logging.Logger, +): + """parse 'describe' cmd line argument + + Args: + parsed_args (argparse.Namespace): parsed cmd line arguments + plugin_reg (PluginRegistry): plugin registry instance + config_reg (ConfigRegistry): config registry instance + logger (logging.Logger): logger instance + """ + if not parsed_args.name: + if parsed_args.type == "config": + print("Available built-in configs:") # noqa: T201 + for name in config_reg.configs: + print(f" {name}") # noqa: T201 + elif parsed_args.type == "plugin": + print("Available plugins:") # noqa: T201 + for name in plugin_reg.plugins: + print(f" {name}") # noqa: T201 + print(f"\nUsage: describe {parsed_args.type} ") # noqa: T201 + sys.exit(0) + + if parsed_args.type == "config": + if parsed_args.name not in config_reg.configs: + logger.error("No config found for name: %s", parsed_args.name) + sys.exit(1) + config_model = config_reg.configs[parsed_args.name] + print(f"Config Name: {parsed_args.name}") # noqa: T201 + print(f"Description: {getattr(config_model, 'desc', '')}") # noqa: T201 + print("Plugins:") # noqa: T201 + for plugin in getattr(config_model, "plugins", []): + print(f"\t{plugin}") # noqa: T201 + + elif parsed_args.type == "plugin": + if parsed_args.name not in plugin_reg.plugins: + logger.error("No plugin found for name: %s", parsed_args.name) + sys.exit(1) + plugin_class = plugin_reg.plugins[parsed_args.name] + print(f"Plugin Name: {parsed_args.name}") # noqa: T201 + print(f"Description: {getattr(plugin_class, '__doc__', '')}") # noqa: T201 + + sys.exit(0) + + +def parse_gen_plugin_config( + parsed_args: argparse.Namespace, + plugin_reg: PluginRegistry, + config_reg: ConfigRegistry, + logger: logging.Logger, +): + """parse 'gen_plugin_config' cmd line argument + + Args: + parsed_args (argparse.Namespace): parsed cmd line arguments + plugin_reg (PluginRegistry): plugin registry instance + config_reg (ConfigRegistry): config registry instance + logger (logging.Logger): logger instance + """ + try: + config = build_config( + config_reg, plugin_reg, logger, parsed_args.plugins, parsed_args.built_in_configs + ) + + config.name = parsed_args.config_name.split(".")[0] + config.desc = "Auto generated config" + output_path = os.path.join(parsed_args.output_path, parsed_args.config_name) + with open(output_path, "w", encoding="utf-8") as out_file: + out_file.write(config.model_dump_json(indent=2)) + + logger.info("Config saved to: %s", output_path) + sys.exit(0) + except Exception: + logger.exception("Exception when building config") + sys.exit(1) + + +def log_system_info(log_path: str | None, system_info: SystemInfo, logger: logging.Logger): + """dump system info object to json log + + Args: + log_path (str): path to log folder + system_info (SystemInfo): system object instance + """ + if log_path: + try: + with open( + os.path.join(log_path, "system_info.json"), "w", encoding="utf-8" + ) as log_file: + json.dump( + system_info.model_dump(mode="json", exclude_none=True), + log_file, + indent=2, + ) + except Exception as exp: + logger.error(exp) + + +def generate_reference_config( + results: list[PluginResult], plugin_reg: PluginRegistry, logger: logging.Logger +) -> PluginConfig: + """Generate reference config from plugin results + + Args: + results (list[PluginResult]): list of plugin results + plugin_reg (PluginRegistry): registry containing all registered plugins + logger (logging.Logger): logger + + Returns: + PluginConfig: holds model that defines final reference config + """ + plugin_config = PluginConfig() + plugins = {} + for obj in results: + if obj.result_data.collection_result.status != ExecutionStatus.OK: + logger.warning( + "Plugin: %s result status is %, skipping", + obj.source, + obj.result_data.collection_result.status, + ) + continue + + data_model = obj.result_data.system_data + if data_model is None: + logger.warning("Plugin: %s data model not found: %s, skipping", obj.source) + continue + + plugin = plugin_reg.plugins.get(obj.source) + if not plugin.ANALYZER_ARGS: + logger.warning( + "Plugin: %s does not support reference config creation. No analyzer args defined, skipping.", + obj.source, + ) + continue + + args = None + try: + args = plugin.ANALYZER_ARGS.build_from_model(data_model) + except NotImplementedError as nperr: + logger.info(nperr) + continue + plugins[obj.source] = {"analysis_args": {}} + plugins[obj.source]["analysis_args"] = args.model_dump(exclude_none=True) + plugin_config.plugins = plugins + + return plugin_config diff --git a/nodescraper/generictypes.py b/nodescraper/generictypes.py index ab881948..5b9cce53 100644 --- a/nodescraper/generictypes.py +++ b/nodescraper/generictypes.py @@ -23,13 +23,14 @@ # SOFTWARE. # ############################################################################### -from typing import Optional, TypeVar +from typing import TYPE_CHECKING, Optional, TypeVar from pydantic import BaseModel -from nodescraper.models import DataModel +if TYPE_CHECKING: + from nodescraper.models import DataModel TDataModel = TypeVar("TDataModel", bound="DataModel") -TModelType = TypeVar("TModelType", bound="BaseModel") -TCollectArg = TypeVar("TCollectArg", bound="Optional[BaseModel]") -TAnalyzeArg = TypeVar("TAnalyzeArg", bound="Optional[BaseModel]") +TModelType = TypeVar("TModelType", bound=BaseModel) +TCollectArg = TypeVar("TCollectArg", bound=Optional[BaseModel]) +TAnalyzeArg = TypeVar("TAnalyzeArg", bound=Optional[BaseModel]) diff --git a/nodescraper/interfaces/dataplugin.py b/nodescraper/interfaces/dataplugin.py index 82587bf0..91235ab2 100644 --- a/nodescraper/interfaces/dataplugin.py +++ b/nodescraper/interfaces/dataplugin.py @@ -31,7 +31,13 @@ from nodescraper.interfaces.dataanalyzertask import DataAnalyzer from nodescraper.interfaces.datacollectortask import DataCollector from nodescraper.interfaces.plugin import PluginInterface -from nodescraper.models import DataPluginResult, PluginResult, SystemInfo, TaskResult +from nodescraper.models import ( + AnalyzerArgs, + DataPluginResult, + PluginResult, + SystemInfo, + TaskResult, +) from .connectionmanager import TConnectArg, TConnectionManager from .task import SystemCompatibilityError @@ -51,6 +57,8 @@ class DataPlugin( ANALYZER: Optional[Type[DataAnalyzer]] = None + ANALYZER_ARGS: Optional[Type[AnalyzerArgs]] = None + def __init__( self, system_info: SystemInfo, diff --git a/nodescraper/models/__init__.py b/nodescraper/models/__init__.py index 35f6f9ce..55a8f286 100644 --- a/nodescraper/models/__init__.py +++ b/nodescraper/models/__init__.py @@ -23,6 +23,7 @@ # SOFTWARE. # ############################################################################### +from .analyzerargs import AnalyzerArgs from .datamodel import DataModel from .datapluginresult import DataPluginResult from .event import Event @@ -30,9 +31,10 @@ from .pluginresult import PluginResult from .systeminfo import SystemInfo from .taskresult import TaskResult -from .timerangeargs import TimeRangeAnalyisArgs +from .timerangeargs import TimeRangeAnalysisArgs __all__ = [ + "AnalyzerArgs", "DataModel", "TaskResult", "Event", @@ -40,5 +42,5 @@ "PluginResult", "DataPluginResult", "PluginConfig", - "TimeRangeAnalyisArgs", + "TimeRangeAnalysisArgs", ] diff --git a/nodescraper/models/analyzerargs.py b/nodescraper/models/analyzerargs.py new file mode 100644 index 00000000..209f6a0e --- /dev/null +++ b/nodescraper/models/analyzerargs.py @@ -0,0 +1,44 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from pydantic import BaseModel + + +class AnalyzerArgs(BaseModel): + model_config = {"extra": "forbid", "exclude_none": True} + + @classmethod + def build_from_model(cls, datamodel): + """Build analyzer args instance from data model object + + Args: + datamodel (TDataModel): data model to use for creating analyzer args + + Raises: + NotImplementedError: Not implemented error + """ + raise NotImplementedError( + "Setting analyzer args from datamodel is not implemented for class: %s", cls.__name__ + ) diff --git a/nodescraper/models/timerangeargs.py b/nodescraper/models/timerangeargs.py index 58b97ee6..33b1400f 100644 --- a/nodescraper/models/timerangeargs.py +++ b/nodescraper/models/timerangeargs.py @@ -26,10 +26,10 @@ import datetime from typing import Optional -from pydantic import BaseModel +from nodescraper.models import AnalyzerArgs -class TimeRangeAnalyisArgs(BaseModel): +class TimeRangeAnalysisArgs(AnalyzerArgs): """ Model for time range analysis arguments. """ diff --git a/nodescraper/plugins/inband/bios/analyzer_args.py b/nodescraper/plugins/inband/bios/analyzer_args.py index ac6ecbca..2d4a5003 100644 --- a/nodescraper/plugins/inband/bios/analyzer_args.py +++ b/nodescraper/plugins/inband/bios/analyzer_args.py @@ -23,15 +23,16 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.os.osdata import OsDataModel -class BiosAnalyzerArgs(BaseModel): + +class BiosAnalyzerArgs(AnalyzerArgs): exp_bios_version: list[str] = Field(default_factory=list) regex_match: bool = False - model_config = {"extra": "forbid"} - @field_validator("exp_bios_version", mode="before") @classmethod def validate_exp_bios_version(cls, exp_bios_version: str | list) -> list: @@ -47,3 +48,15 @@ def validate_exp_bios_version(cls, exp_bios_version: str | list) -> list: exp_bios_version = [exp_bios_version] return exp_bios_version + + @classmethod + def build_from_model(cls, datamodel: OsDataModel) -> "BiosAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (BiosDataModel): data model for plugin + + Returns: + BiosAnalyzerArgs: instance of analyzer args class + """ + return cls(exp_bios_version=datamodel.bios_version) diff --git a/nodescraper/plugins/inband/bios/bios_plugin.py b/nodescraper/plugins/inband/bios/bios_plugin.py index a7166333..fd687f70 100644 --- a/nodescraper/plugins/inband/bios/bios_plugin.py +++ b/nodescraper/plugins/inband/bios/bios_plugin.py @@ -39,3 +39,5 @@ class BiosPlugin(InBandDataPlugin[BiosDataModel, None, BiosAnalyzerArgs]): COLLECTOR = BiosCollector ANALYZER = BiosAnalyzer + + ANALYZER_ARGS = BiosAnalyzerArgs diff --git a/nodescraper/plugins/inband/cmdline/analyzer_args.py b/nodescraper/plugins/inband/cmdline/analyzer_args.py index be47f4fd..cbd9b16f 100644 --- a/nodescraper/plugins/inband/cmdline/analyzer_args.py +++ b/nodescraper/plugins/inband/cmdline/analyzer_args.py @@ -23,15 +23,16 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.cmdline.cmdlinedata import CmdlineDataModel -class CmdlineAnalyzerArgs(BaseModel): + +class CmdlineAnalyzerArgs(AnalyzerArgs): required_cmdline: str | list = Field(default_factory=list) banned_cmdline: str | list = Field(default_factory=list) - model_config = {"extra": "forbid"} - @field_validator("required_cmdline", mode="before") @classmethod def validate_required_cmdline(cls, required_cmdline: str | list) -> list: @@ -63,3 +64,15 @@ def validate_banned_cmdline(cls, banned_cmdline: str | list) -> list: banned_cmdline = [banned_cmdline] return banned_cmdline + + @classmethod + def build_from_model(cls, datamodel: CmdlineDataModel) -> "CmdlineAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (CmdlineDataModel): data model for plugin + + Returns: + CmdlineAnalyzerArgs: instance of analyzer args class + """ + return cls(required_cmdline=datamodel.cmdline) diff --git a/nodescraper/plugins/inband/cmdline/cmdline_plugin.py b/nodescraper/plugins/inband/cmdline/cmdline_plugin.py index 5e1401ce..2c5fefca 100644 --- a/nodescraper/plugins/inband/cmdline/cmdline_plugin.py +++ b/nodescraper/plugins/inband/cmdline/cmdline_plugin.py @@ -39,3 +39,5 @@ class CmdlinePlugin(InBandDataPlugin[CmdlineDataModel, None, CmdlineAnalyzerArgs COLLECTOR = CmdlineCollector ANALYZER = CmdlineAnalyzer + + ANALYZER_ARGS = CmdlineAnalyzerArgs diff --git a/nodescraper/plugins/inband/dkms/analyzer_args.py b/nodescraper/plugins/inband/dkms/analyzer_args.py index e4fa7193..0d4ab6da 100644 --- a/nodescraper/plugins/inband/dkms/analyzer_args.py +++ b/nodescraper/plugins/inband/dkms/analyzer_args.py @@ -25,16 +25,17 @@ ############################################################################### from typing import Any -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.dkms.dkmsdata import DkmsDataModel -class DkmsAnalyzerArgs(BaseModel): + +class DkmsAnalyzerArgs(AnalyzerArgs): dkms_status: str | list = Field(default_factory=list) dkms_version: str | list = Field(default_factory=list) regex_match: bool = False - model_config = {"extra": "forbid"} - def model_post_init(self, __context: Any) -> None: if not self.dkms_status and not self.dkms_version: raise ValueError("At least one of dkms_status or dkms_version must be provided") @@ -70,3 +71,15 @@ def validate_dkms_version(cls, dkms_version: str | list) -> list: dkms_version = [dkms_version] return dkms_version + + @classmethod + def build_from_model(cls, datamodel: DkmsDataModel) -> "DkmsAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (DkmsDataModel): data model for plugin + + Returns: + DkmsAnalyzerArgs: instance of analyzer args class + """ + return cls(dkms_status=datamodel.status, dkms_version=datamodel.version) diff --git a/nodescraper/plugins/inband/dkms/dkms_plugin.py b/nodescraper/plugins/inband/dkms/dkms_plugin.py index cc8d18ed..20963950 100644 --- a/nodescraper/plugins/inband/dkms/dkms_plugin.py +++ b/nodescraper/plugins/inband/dkms/dkms_plugin.py @@ -39,3 +39,5 @@ class DkmsPlugin(InBandDataPlugin[DkmsDataModel, None, DkmsAnalyzerArgs]): COLLECTOR = DkmsCollector ANALYZER = DkmsAnalyzer + + ANALYZER_ARGS = DkmsAnalyzerArgs diff --git a/nodescraper/plugins/inband/dmesg/analyzer_args.py b/nodescraper/plugins/inband/dmesg/analyzer_args.py index aa0eb2f7..62bd7bd3 100644 --- a/nodescraper/plugins/inband/dmesg/analyzer_args.py +++ b/nodescraper/plugins/inband/dmesg/analyzer_args.py @@ -25,11 +25,9 @@ ############################################################################### from typing import Optional -from nodescraper.models import TimeRangeAnalyisArgs +from nodescraper.models import TimeRangeAnalysisArgs -class DmesgAnalyzerArgs(TimeRangeAnalyisArgs): +class DmesgAnalyzerArgs(TimeRangeAnalysisArgs): check_unknown_dmesg_errors: Optional[bool] = True exclude_category: Optional[set[str]] = None - - model_config = {"extra": "forbid"} diff --git a/nodescraper/plugins/inband/kernel/analyzer_args.py b/nodescraper/plugins/inband/kernel/analyzer_args.py index b6212987..f8ba1a28 100644 --- a/nodescraper/plugins/inband/kernel/analyzer_args.py +++ b/nodescraper/plugins/inband/kernel/analyzer_args.py @@ -23,15 +23,16 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.kernel.kerneldata import KernelDataModel -class KernelAnalyzerArgs(BaseModel): + +class KernelAnalyzerArgs(AnalyzerArgs): exp_kernel: str | list = Field(default_factory=list) regex_match: bool = False - model_config = {"extra": "forbid"} - @field_validator("exp_kernel", mode="before") @classmethod def validate_exp_kernel(cls, exp_kernel: str | list) -> list: @@ -47,3 +48,15 @@ def validate_exp_kernel(cls, exp_kernel: str | list) -> list: exp_kernel = [exp_kernel] return exp_kernel + + @classmethod + def build_from_model(cls, datamodel: KernelDataModel) -> "KernelAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (KernelDataModel): data model for plugin + + Returns: + KernelAnalyzerArgs: instance of analyzer args class + """ + return cls(exp_kernel=datamodel.kernel_version) diff --git a/nodescraper/plugins/inband/kernel/kernel_plugin.py b/nodescraper/plugins/inband/kernel/kernel_plugin.py index 33dfbc74..565fd486 100644 --- a/nodescraper/plugins/inband/kernel/kernel_plugin.py +++ b/nodescraper/plugins/inband/kernel/kernel_plugin.py @@ -39,3 +39,5 @@ class KernelPlugin(InBandDataPlugin[KernelDataModel, None, KernelAnalyzerArgs]): COLLECTOR = KernelCollector ANALYZER = KernelAnalyzer + + ANALYZER_ARGS = KernelAnalyzerArgs diff --git a/nodescraper/plugins/inband/memory/analyzer_args.py b/nodescraper/plugins/inband/memory/analyzer_args.py index 1f58840f..cc5f0ef4 100644 --- a/nodescraper/plugins/inband/memory/analyzer_args.py +++ b/nodescraper/plugins/inband/memory/analyzer_args.py @@ -29,5 +29,3 @@ class MemoryAnalyzerArgs(BaseModel): ratio: float = 0.66 memory_threshold: str = "30Gi" - - model_config = {"extra": "forbid"} diff --git a/nodescraper/plugins/inband/os/analyzer_args.py b/nodescraper/plugins/inband/os/analyzer_args.py index 7012f114..56719ebc 100644 --- a/nodescraper/plugins/inband/os/analyzer_args.py +++ b/nodescraper/plugins/inband/os/analyzer_args.py @@ -23,10 +23,13 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.os.osdata import OsDataModel -class OsAnalyzerArgs(BaseModel): + +class OsAnalyzerArgs(AnalyzerArgs): exp_os: str | list = Field(default_factory=list) exact_match: bool = True @@ -45,3 +48,15 @@ def validate_exp_os(cls, exp_os: str | list) -> list: exp_os = [exp_os] return exp_os + + @classmethod + def build_from_model(cls, datamodel: OsDataModel) -> "OsAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (OsDataModel): data model for plugin + + Returns: + OsAnalyzerArgs: instance of analyzer args class + """ + return cls(exp_os=datamodel.os_version) diff --git a/nodescraper/plugins/inband/os/os_plugin.py b/nodescraper/plugins/inband/os/os_plugin.py index 96cf2a72..9cdb2437 100644 --- a/nodescraper/plugins/inband/os/os_plugin.py +++ b/nodescraper/plugins/inband/os/os_plugin.py @@ -39,3 +39,5 @@ class OsPlugin(InBandDataPlugin[OsDataModel, None, OsAnalyzerArgs]): COLLECTOR = OsCollector ANALYZER = OsAnalyzer + + ANALYZER_ARGS = OsAnalyzerArgs diff --git a/nodescraper/plugins/inband/package/analyzer_args.py b/nodescraper/plugins/inband/package/analyzer_args.py index 2ea9d3de..c5ab1b6b 100644 --- a/nodescraper/plugins/inband/package/analyzer_args.py +++ b/nodescraper/plugins/inband/package/analyzer_args.py @@ -23,11 +23,16 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel, Field +from pydantic import Field +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.package.packagedata import PackageDataModel -class PackageAnalyzerArgs(BaseModel): + +class PackageAnalyzerArgs(AnalyzerArgs): exp_package_ver: dict[str, str | None] = Field(default_factory=dict) regex_match: bool = True - model_config = {"extra": "forbid"} + @classmethod + def build_from_model(cls, datamodel: PackageDataModel) -> "PackageAnalyzerArgs": + return cls(exp_package_ver=datamodel.version_info) diff --git a/nodescraper/plugins/inband/package/package_plugin.py b/nodescraper/plugins/inband/package/package_plugin.py index 38dff7e7..3cf4b414 100644 --- a/nodescraper/plugins/inband/package/package_plugin.py +++ b/nodescraper/plugins/inband/package/package_plugin.py @@ -39,3 +39,5 @@ class PackagePlugin(InBandDataPlugin[PackageDataModel, None, PackageAnalyzerArgs COLLECTOR = PackageCollector ANALYZER = PackageAnalyzer + + ANALYZER_ARGS = PackageAnalyzerArgs diff --git a/nodescraper/plugins/inband/process/analyzer_args.py b/nodescraper/plugins/inband/process/analyzer_args.py index 5159288d..135a472b 100644 --- a/nodescraper/plugins/inband/process/analyzer_args.py +++ b/nodescraper/plugins/inband/process/analyzer_args.py @@ -23,11 +23,23 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel +from nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.process.processdata import ProcessDataModel -class ProcessAnalyzerArgs(BaseModel): + +class ProcessAnalyzerArgs(AnalyzerArgs): max_kfd_processes: int = 0 - max_cpu_usage: int = 20 + max_cpu_usage: float = 20.0 + + @classmethod + def build_from_model(cls, datamodel: ProcessDataModel) -> "ProcessAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (ProcessDataModel): data model for plugin - model_config = {"extra": "forbid"} + Returns: + ProcessAnalyzerArgs: instance of analyzer args class + """ + return cls(max_kfd_processes=datamodel.kfd_process, max_cpu_usage=datamodel.cpu_usage) diff --git a/nodescraper/plugins/inband/process/process_plugin.py b/nodescraper/plugins/inband/process/process_plugin.py index 82840654..2bfcf7e0 100644 --- a/nodescraper/plugins/inband/process/process_plugin.py +++ b/nodescraper/plugins/inband/process/process_plugin.py @@ -40,3 +40,5 @@ class ProcessPlugin(InBandDataPlugin[ProcessDataModel, ProcessCollectorArgs, Pro COLLECTOR = ProcessCollector ANALYZER = ProcessAnalyzer + + ANALYZER_ARGS = ProcessAnalyzerArgs diff --git a/nodescraper/plugins/inband/rocm/analyzer_args.py b/nodescraper/plugins/inband/rocm/analyzer_args.py index e7e7023c..190e5f4c 100644 --- a/nodescraper/plugins/inband/rocm/analyzer_args.py +++ b/nodescraper/plugins/inband/rocm/analyzer_args.py @@ -25,12 +25,12 @@ ############################################################################### from pydantic import BaseModel, Field, field_validator +from nodescraper.plugins.inband.rocm.rocmdata import RocmDataModel + class RocmAnalyzerArgs(BaseModel): exp_rocm: str | list = Field(default_factory=list) - model_config = {"extra": "forbid"} - @field_validator("exp_rocm", mode="before") @classmethod def validate_exp_rocm(cls, exp_rocm: str | list) -> list: @@ -46,3 +46,15 @@ def validate_exp_rocm(cls, exp_rocm: str | list) -> list: exp_rocm = [exp_rocm] return exp_rocm + + @classmethod + def build_from_model(cls, datamodel: RocmDataModel) -> "RocmAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (RocmDataModel): data model for plugin + + Returns: + RocmAnalyzerArgs: instance of analyzer args class + """ + return cls(exp_rocm=datamodel.rocm_version) diff --git a/nodescraper/plugins/inband/rocm/rocm_plugin.py b/nodescraper/plugins/inband/rocm/rocm_plugin.py index 9fb141dc..9a3cfa3d 100644 --- a/nodescraper/plugins/inband/rocm/rocm_plugin.py +++ b/nodescraper/plugins/inband/rocm/rocm_plugin.py @@ -39,3 +39,5 @@ class RocmPlugin(InBandDataPlugin[RocmDataModel, None, RocmAnalyzerArgs]): COLLECTOR = RocmCollector ANALYZER = RocmAnalyzer + + ANALYZER_ARGS = RocmAnalyzerArgs diff --git a/nodescraper/plugins/inband/storage/analyzer_args.py b/nodescraper/plugins/inband/storage/analyzer_args.py index 5c061e41..1f44d1d3 100644 --- a/nodescraper/plugins/inband/storage/analyzer_args.py +++ b/nodescraper/plugins/inband/storage/analyzer_args.py @@ -34,5 +34,3 @@ class StorageAnalyzerArgs(BaseModel): ignore_devices: Optional[list[str]] = Field(default_factory=list) check_devices: Optional[list[str]] = Field(default_factory=list) regex_match: bool = False - - model_config = {"extra": "forbid"} diff --git a/test/unit/framework/common/shared_utils.py b/test/unit/framework/common/shared_utils.py index 52a50c9c..7ebe6720 100644 --- a/test/unit/framework/common/shared_utils.py +++ b/test/unit/framework/common/shared_utils.py @@ -26,11 +26,10 @@ from typing import Optional from unittest.mock import MagicMock -from pydantic import BaseModel - from nodescraper.enums import ExecutionStatus from nodescraper.interfaces import ConnectionManager, PluginInterface -from nodescraper.models import PluginResult, TaskResult +from nodescraper.models import AnalyzerArgs, PluginResult, TaskResult +from nodescraper.models.datamodel import DataModel class MockConnectionManager(ConnectionManager): @@ -68,13 +67,22 @@ def disconnect(self): pass -class TestModelArg(BaseModel): +class TestModelArg(AnalyzerArgs): model_attr: int = 123 + @classmethod + def build_from_model(cls, model): + return cls(model_attr=int(model.some_version)) + + +class DummyDataModel(DataModel): + some_version: str = None + class TestPluginA(PluginInterface[MockConnectionManager, None]): CONNECTION_TYPE = MockConnectionManager + ANALYZER_ARGS = TestModelArg def run( self, diff --git a/test/unit/framework/test_analyzerargs.py b/test/unit/framework/test_analyzerargs.py new file mode 100644 index 00000000..3642a2b6 --- /dev/null +++ b/test/unit/framework/test_analyzerargs.py @@ -0,0 +1,25 @@ +import pytest + +from nodescraper.models import AnalyzerArgs + + +class MyArgs(AnalyzerArgs): + args_foo: int + + @classmethod + def build_from_model(cls, datamodel): + return cls(args_foo=datamodel.foo) + + +def test_build_from_model(dummy_data_model): + dummy = dummy_data_model(foo=1) + args = MyArgs.build_from_model(dummy) + assert isinstance(args, MyArgs) + assert args.args_foo == dummy.foo + dump = args.model_dump(mode="json", exclude_none=True) + assert dump == {"args_foo": 1} + + +def test_base_build_from_model_not_implemented(): + with pytest.raises(NotImplementedError): + AnalyzerArgs.build_from_model("anything") diff --git a/test/unit/framework/test_cli.py b/test/unit/framework/test_cli.py index 6205ebd1..d4abdab0 100644 --- a/test/unit/framework/test_cli.py +++ b/test/unit/framework/test_cli.py @@ -28,12 +28,16 @@ import os import pytest +from common.shared_utils import DummyDataModel from pydantic import BaseModel from nodescraper.cli import cli, inputargtypes +from nodescraper.cli.helper import build_config from nodescraper.configregistry import ConfigRegistry -from nodescraper.enums import SystemInteractionLevel, SystemLocation -from nodescraper.models import PluginConfig, SystemInfo +from nodescraper.enums import ExecutionStatus, SystemInteractionLevel, SystemLocation +from nodescraper.models import PluginConfig, SystemInfo, TaskResult +from nodescraper.models.datapluginresult import DataPluginResult +from nodescraper.models.pluginresult import PluginResult def test_log_path_arg(): @@ -200,7 +204,7 @@ def test_get_plugin_configs(): def test_config_builder(plugin_registry): - config = cli.build_config( + config = build_config( config_reg=ConfigRegistry(config_path=os.path.join(os.path.dirname(__file__), "fixtures")), plugin_reg=plugin_registry, logger=logging.getLogger(), @@ -215,3 +219,27 @@ def test_config_builder(plugin_registry): }, "ExamplePlugin": {}, } + + +def test_generate_reference_config(plugin_registry): + results = [ + PluginResult( + status=ExecutionStatus.OK, + source="TestPluginA", + message="Plugin tasks completed successfully", + result_data=DataPluginResult( + system_data=DummyDataModel(some_version="17"), + collection_result=TaskResult( + status=ExecutionStatus.OK, + message="BIOS: 17", + task="BiosCollector", + parent="TestPluginA", + artifacts=[], + ), + ), + ) + ] + + ref_config = cli.generate_reference_config(results, plugin_registry, logging.getLogger()) + dump = ref_config.dict() + assert dump["plugins"] == {"TestPluginA": {"analysis_args": {"model_attr": 17}}}