diff --git a/nodescraper/plugins/serviceability/analysis_window.py b/nodescraper/plugins/serviceability/analysis_window.py index 911246e1..d0721f1f 100644 --- a/nodescraper/plugins/serviceability/analysis_window.py +++ b/nodescraper/plugins/serviceability/analysis_window.py @@ -35,7 +35,12 @@ from .analyzer_args import ServiceabilityAnalyzerArgs from .cper_decode import CperDecodeError, decode_cper_raw_attachments from .se_models import AfidEvent, ServiceabilityBlock -from .se_runner import HubRunError, run_entry_point_hub, run_service_hub +from .se_runner import ( + HubRunError, + resolve_runtime_afid_sag_path, + run_entry_point_hub, + run_service_hub, +) from .serviceability_data import ServiceabilityDataModel @@ -161,7 +166,11 @@ def analyze_serviceability_window( error="empty afid_events", ) - sag_path = args.resolved_afid_sag_path() + sag_path = ( + resolve_runtime_afid_sag_path(args, log) + if args.uses_entry_point_hub() + else args.resolved_afid_sag_path() + ) data.afid_sag_path = sag_path log.info( "(%s) Using AFID_SAG file: %s", @@ -191,6 +200,7 @@ def analyze_serviceability_window( rf_event_count=len(data.rf_events), raise_on_error=args.hub_raise_on_error, prefer_rf_events=args.hub_prefer_rf_events and not bool(cper_data), + logger=log, ) except (HubRunError, ValueError) as exc: return ServiceabilityWindowResult( diff --git a/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer.py b/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer.py index 6676a370..61159a1c 100644 --- a/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer.py +++ b/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer.py @@ -25,10 +25,21 @@ ############################################################################### from __future__ import annotations +import json +from datetime import datetime, timezone +from typing import Any + +from nodescraper.connection.inband import TextFileArtifact +from nodescraper.plugins.serviceability.se_models import ServiceabilityBlock +from nodescraper.plugins.serviceability.serviceability_api import ( + split_recommendation_actions, +) from nodescraper.plugins.serviceability.serviceability_hub_analyzer import ( ServiceabilityHubAnalyzer, ) +SUMMARY_FILENAME = "summary.json" + class MI4XXAnalyzer(ServiceabilityHubAnalyzer): """Build AFID events from collected data and run the configured entry-point hub.""" @@ -38,4 +49,36 @@ class MI4XXAnalyzer(ServiceabilityHubAnalyzer): "Runs the configured entry-point service hub (hub_entry_point in analysis_args) to produce service recommendations.", "When analysis_args.skip_hub is true, only builds AFID events without running the hub.", "Supports offline analysis from a prior collection via --data with --collection False.", + f"Writes the top service action to {SUMMARY_FILENAME} in the analyzer log directory.", ) + + def _append_hub_artifacts(self, block: ServiceabilityBlock) -> None: + super()._append_hub_artifacts(block) + self.result.artifacts.append( + TextFileArtifact( + filename=SUMMARY_FILENAME, + contents=json.dumps(self._top_action_summary(block), indent=2), + ) + ) + + def _top_action_summary(self, block: ServiceabilityBlock) -> dict[str, Any]: + """Build the summary.json payload for the top Hub service action. + + Args: + block: Serviceability block returned by the hub. + + Returns: + System name, analysis time, hub and SAG versions, AFID event count, and the + top service action(s). Ties for the top rank are all included. + """ + top, _ = split_recommendation_actions(block) + return { + "system_name": self.system_info.name, + "analyzed_at": datetime.now(timezone.utc).isoformat(), + "hub_version": block.hub_version, + "afid_sag_file_version": block.afid_sag_file_version, + "afid_event_count": len(block.afid_events), + "top_service_actions": [ + action.model_dump(mode="json", exclude_none=True) for action in top + ], + } diff --git a/nodescraper/plugins/serviceability/mi4xx/serviceability_plugin_mi4xx.py b/nodescraper/plugins/serviceability/mi4xx/serviceability_plugin_mi4xx.py index 464b7b88..30a1d1e6 100644 --- a/nodescraper/plugins/serviceability/mi4xx/serviceability_plugin_mi4xx.py +++ b/nodescraper/plugins/serviceability/mi4xx/serviceability_plugin_mi4xx.py @@ -32,6 +32,7 @@ from nodescraper.enums import EventPriority, ExecutionStatus, SystemInteractionLevel from nodescraper.models import DataPluginResult, PluginResult, TaskResult from nodescraper.plugins.serviceability.afid_sag_lookup import log_afid_fru_summary +from nodescraper.plugins.serviceability.se_runner import HubRunError, preflight_afid_sag from nodescraper.plugins.serviceability.serviceability_data import ( ServiceabilityDataLoadError, ServiceabilityDataModel, @@ -198,6 +199,22 @@ def run( # type: ignore[override] analysis_args, ) + if analysis: + try: + parsed_analysis_args = ( + analysis_args + if isinstance(analysis_args, Mi4xxServiceabilityAnalyzerArgs) + else Mi4xxServiceabilityAnalyzerArgs(**(analysis_args or {})) + ) + if not parsed_analysis_args.skip_hub: + collection_args, analysis_args = self._merge_afid_sag_path( + preflight_afid_sag(parsed_analysis_args, self.logger), + collection_args, + analysis_args, + ) + except (HubRunError, ValueError) as exc: + return self._plugin_error_result(f"AFID_SAG preflight failed: {exc}") + if analysis and not collection and data is not None: try: loaded = ( diff --git a/nodescraper/plugins/serviceability/se_runner.py b/nodescraper/plugins/serviceability/se_runner.py index dc7b74de..86789312 100644 --- a/nodescraper/plugins/serviceability/se_runner.py +++ b/nodescraper/plugins/serviceability/se_runner.py @@ -31,16 +31,22 @@ import importlib import importlib.metadata import inspect +import logging from pathlib import Path -from typing import Any, Callable, Optional, Protocol, Type, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, Protocol, Type, Union, cast -from .afid_sag_paths import validate_afid_sag_path +from nodescraper.constants import DEFAULT_LOGGER + +from .afid_sag_paths import default_afid_sag_path, validate_afid_sag_path from .se_adapter import ( serviceability_block_from_entry_point_hub, serviceability_block_from_service_result, ) from .se_models import AfidEvent, ServiceabilityBlock +if TYPE_CHECKING: + from .analyzer_args import ServiceabilityAnalyzerArgs + HUB_ENTRY_POINT_GROUP = "amd.serviceability_engines" @@ -292,15 +298,71 @@ def events_payload_for_hub_analyze( raise HubRunError("rf_events or afid_events is required") +def _usable_serviceable_units(units: Any) -> list[Any]: + """Return ServiceableUnits entries that carry an '@odata.id' string.""" + if not isinstance(units, list): + return [] + return [ + unit for unit in units if isinstance(unit, dict) and isinstance(unit.get("@odata.id"), str) + ] + + +def drop_afids_without_serviceable_units( + rf_events: list[Any], + logger: Optional[logging.Logger] = None, +) -> list[Any]: + """Waive AMDFieldIdentifiers entries the hub would reject for lacking ServiceableUnits. + + The entry-point hub rejects the whole event list when any Oem.AMD.AMDFieldIdentifiers + entry has no ServiceableUnits, so drop those entries (with a warning) before analysis. + + Args: + rf_events: Raw Redfish log members. + logger: Logger for the per-entry warning. Defaults to the nodescraper logger. + + Returns: + Members with unusable AFID entries removed. Members left with no AFID entries are dropped. + """ + log = logger or logging.getLogger(DEFAULT_LOGGER) + kept: list[Any] = [] + for member in rf_events: + oem = member.get("Oem") if isinstance(member, dict) else None + amd = oem.get("AMD") if isinstance(oem, dict) else None + identifiers = amd.get("AMDFieldIdentifiers") if isinstance(amd, dict) else None + if not isinstance(identifiers, list): + kept.append(member) + continue + usable = [] + for entry in identifiers: + if isinstance(entry, dict) and _usable_serviceable_units(entry.get("ServiceableUnits")): + usable.append(entry) + continue + afid = entry.get("AFID") if isinstance(entry, dict) else None + log.warning( + "Waiving AFID %s from %s: AMDFieldIdentifiers entry has no ServiceableUnits", + afid, + member.get("@odata.id") or member.get("Id") or "", + ) + if len(usable) == len(identifiers): + kept.append(member) + elif usable: + amd_copy = {**cast(dict, amd), "AMDFieldIdentifiers": usable} + kept.append({**member, "Oem": {**cast(dict, oem), "AMD": amd_copy}}) + return kept + + def resolve_hub_events_payload( *, afid_events: list[AfidEvent], rf_events: Optional[list[Any]] = None, prefer_rf_events: bool = True, + logger: Optional[logging.Logger] = None, ) -> list[Any]: """Choose rf_events or aggregated afid_events for hub analyze().""" if prefer_rf_events and rf_events: - return events_payload_for_hub_analyze(rf_events=rf_events) + usable_rf_events = drop_afids_without_serviceable_units(rf_events, logger) + if usable_rf_events: + return events_payload_for_hub_analyze(rf_events=usable_rf_events) return events_payload_for_hub_analyze( afid_events_payload=afid_events_to_entry_point_payload(afid_events) ) @@ -444,6 +506,107 @@ def _hub_analyze_response_from_raw(raw_result: Any) -> dict[str, Any]: return dataclasses.asdict(raw_result) +def _response_field(result: Any, name: str) -> Any: + """Read a field from an AFSE TriageResponse dataclass or a dict payload.""" + if isinstance(result, dict): + return result.get(name) + return getattr(result, name, None) + + +def validate_afid_sag_with_hub(hub: Any, sag_path: str) -> bool: + """Ask the loaded hub to validate the AFID SAG file via its validate() API. + + Args: + hub: Loaded entry-point hub (AFSE). + sag_path: AFID SAG file to validate. + + Returns: + True when the hub validated the file, False when the hub has no validate(). + + Raises: + HubRunError: When validate() raises or reports a status other than ok. + """ + validate = getattr(hub, "validate", None) + if not callable(validate): + return False + try: + result = validate(sag_path) + except Exception as exc: # noqa: BLE001 + raise HubRunError(f"AFSE SAG validation failed: {exc}") from exc + status = str(_response_field(result, "status") or "").strip().lower() + if status == "ok": + return True + error = _response_field(result, "error") + code = _response_field(error, "code") if error is not None else None + message = _response_field(error, "message") if error is not None else None + detail = str(message or code or status or "unknown error") + raise HubRunError(f"AFSE SAG validation failed: {detail}") + + +def resolve_runtime_afid_sag_path( + args: ServiceabilityAnalyzerArgs, + logger: Optional[logging.Logger] = None, +) -> str: + """Return the AFID SAG file analysis should open. + + A configured afid_sag_path wins. Otherwise the loaded entry-point hub is asked for its + default_sag_path(), falling back to the built-in /opt/amd/afid/AFID_SAG.json. + + Args: + args: Analyzer args for this run. + logger: Logger for the "path omitted" line. Defaults to the nodescraper logger. + + Returns: + SAG path to use for analysis. + """ + if args.afid_sag_path and str(args.afid_sag_path).strip(): + return str(args.afid_sag_path).strip() + hub: Any = None + if args.uses_entry_point_hub(): + try: + hub = load_hub_from_entry_point(args.resolved_hub_entry_point()) + except (HubRunError, ValueError): + hub = None + path = default_afid_sag_path() + lookup = getattr(hub, "default_sag_path", None) + if callable(lookup): + found = lookup() + if found is not None and str(found).strip(): + path = str(found).strip() + (logger or logging.getLogger(DEFAULT_LOGGER)).info( + "AFID_SAG path omitted; using default: %s", path + ) + return path + + +def preflight_afid_sag( + args: ServiceabilityAnalyzerArgs, + logger: Optional[logging.Logger] = None, +) -> str: + """Resolve the AFID SAG file and check it with AFSE before any collection runs. + + Args: + args: Analyzer args for this run (entry-point hub). + logger: Logger for the OK lines. Defaults to the nodescraper logger. + + Returns: + Resolved absolute SAG path. + + Raises: + HubRunError: When the file is missing, the hub cannot load, or AFSE rejects the file. + """ + log = logger or logging.getLogger(DEFAULT_LOGGER) + sag_path = resolve_runtime_afid_sag_path(args, log) + validate_afid_sag_path(sag_path) + resolved = str(Path(sag_path).expanduser().resolve()) + log.info("AFID_SAG file OK: %s", resolved) + hub_name = args.resolved_hub_entry_point() + hub = load_hub_from_entry_point(hub_name) + if validate_afid_sag_with_hub(hub, resolved): + log.info("AFID_SAG validated by %s: %s", getattr(hub, "name", hub_name), resolved) + return resolved + + def run_entry_point_hub( *, hub_entry_point: str, @@ -454,20 +617,27 @@ def run_entry_point_hub( rf_event_count: int = 0, raise_on_error: bool = False, prefer_rf_events: bool = True, + logger: Optional[logging.Logger] = None, ) -> ServiceabilityBlock: """Run a registered entry-point service hub and return a :class:`ServiceabilityBlock`.""" if not afid_events and not (prefer_rf_events and rf_events): raise HubRunError("No AFID events to analyze") + log = logger or logging.getLogger(DEFAULT_LOGGER) validate_afid_sag_path(afid_sag_path) + resolved_sag = Path(afid_sag_path).expanduser().resolve() + log.debug("AFID_SAG file OK: %s", resolved_sag) label = hub_display_name or normalize_hub_entry_point(hub_entry_point) hub = load_hub_from_entry_point(hub_entry_point) + if validate_afid_sag_with_hub(hub, str(resolved_sag)): + log.debug("AFID_SAG validated by %s: %s", getattr(hub, "name", label), resolved_sag) hub_label = getattr(hub, "name", label) engine_version = _hub_engine_version(hub) events = resolve_hub_events_payload( afid_events=afid_events, rf_events=rf_events, prefer_rf_events=prefer_rf_events, + logger=logger, ) try: raw_result = hub.analyze(events, afid_sag_path) diff --git a/nodescraper/plugins/serviceability/serviceability_recommendations_table.py b/nodescraper/plugins/serviceability/serviceability_recommendations_table.py index 2721e764..9816e95b 100644 --- a/nodescraper/plugins/serviceability/serviceability_recommendations_table.py +++ b/nodescraper/plugins/serviceability/serviceability_recommendations_table.py @@ -45,10 +45,6 @@ ) PRIMARY_RECOMMENDATION_TITLE = "Top Hub service action" -ADDITIONAL_RECOMMENDATIONS_TITLE = "Additional Hub service actions" -ADDITIONAL_RECOMMENDATIONS_NOTE = ( - "Lower-priority follow-on actions if the top service action does not resolve the issue." -) RECOMMENDATION_TABLE_MAX_WIDTHS: dict[str, int] = { "Rank": 4, @@ -197,25 +193,16 @@ def render_serviceability_recommendation_tables_for_plugin_results( def render_serviceability_recommendation_tables(block: ServiceabilityBlock) -> str: - """Render primary and additional Hub recommendation tables.""" - primary, additional = split_recommendation_actions(block) - sections: list[str] = [] - primary_section = render_recommendations_section( + """Render the top Hub recommendation table. + + Lower-ranked actions are not printed; they remain in the serviceability block + saved with the plugin result. + """ + primary, _ = split_recommendation_actions(block) + return render_recommendations_section( _recommendation_section_title(PRIMARY_RECOMMENDATION_TITLE, primary), primary, ) - if primary_section: - sections.append(primary_section) - additional_section = render_recommendations_section( - _recommendation_section_title(ADDITIONAL_RECOMMENDATIONS_TITLE, additional), - additional, - note=ADDITIONAL_RECOMMENDATIONS_NOTE, - ) - if additional_section: - sections.append(additional_section) - if not sections: - return "" - return "\n\n".join(sections) def emit_serviceability_recommendation_tables(block: ServiceabilityBlock) -> None: diff --git a/test/unit/plugin/test_mi4xx_plugin.py b/test/unit/plugin/test_mi4xx_plugin.py index 25964cd6..76296ff8 100644 --- a/test/unit/plugin/test_mi4xx_plugin.py +++ b/test/unit/plugin/test_mi4xx_plugin.py @@ -23,6 +23,7 @@ # SOFTWARE. # ############################################################################### +import json from types import SimpleNamespace from unittest.mock import patch @@ -348,6 +349,38 @@ def test_mi4xx_analyzer_appends_afid_sag_metadata_artifact(system_info, tmp_path assert any(isinstance(artifact, AfidSagMetadataArtifact) for artifact in task.artifacts) +def test_mi4xx_analyzer_writes_top_action_summary_json(system_info, tmp_path): + sag = tmp_path / "afid_sag.json" + sag.write_text("{}", encoding="utf-8") + data = ServiceabilityDataModel( + rf_events=[ + { + "Afid": DUMMY_AFID_A, + "ServiceableUnit": DUMMY_UNIT_A, + "Created": DUMMY_TIMESTAMP, + } + ] + ) + fake_ep = SimpleNamespace(name="afse", load=lambda: _FakeHub) + analyzer = MI4XXAnalyzer(system_info=system_info) + with patch( + "nodescraper.plugins.serviceability.se_runner._entry_points_for_group", + return_value=[fake_ep], + ): + task = analyzer.analyze_data( + data, + Mi4xxServiceabilityAnalyzerArgs(afid_sag_path=str(sag)), + ) + log_dir = tmp_path / "mi4xx_analyzer" + task.log_result(str(log_dir)) + + summary = json.loads((log_dir / "summary.json").read_text(encoding="utf-8")) + assert summary["system_name"] == system_info.name + assert summary["afid_event_count"] == 1 + assert [action["afid"] for action in summary["top_service_actions"]] == [DUMMY_AFID_A] + assert summary["top_service_actions"][0]["rank"] == 1 + + def test_mi4xx_plugin_analyzes_offline_data_without_collection(system_info, tmp_path): sag = tmp_path / "afid_sag.json" sag.write_text("{}", encoding="utf-8") @@ -378,6 +411,56 @@ def test_mi4xx_plugin_analyzes_offline_data_without_collection(system_info, tmp_ assert result.result_data.analysis_result.status == ExecutionStatus.OK +def test_mi4xx_plugin_missing_sag_stops_before_collection(system_info, tmp_path): + fake_ep = SimpleNamespace(name="afse", load=lambda: _FakeHub) + plugin = Mi4xxServiceabilityPlugin(system_info=system_info) + with ( + patch( + "nodescraper.plugins.serviceability.se_runner._entry_points_for_group", + return_value=[fake_ep], + ), + patch.object(MI4XXCollector, "collect_data") as collect_data, + ): + result = plugin.run( + collection=True, + analysis=True, + analysis_args=Mi4xxServiceabilityAnalyzerArgs( + afid_sag_path=str(tmp_path / "missing_sag.json") + ), + ) + collect_data.assert_not_called() + assert result.status == ExecutionStatus.ERROR + assert "AFID_SAG preflight failed: AFID SAG file not found" in result.message + assert result.result_data.collection_result.status == ExecutionStatus.NOT_RAN + + +def test_mi4xx_plugin_sag_rejected_by_afse_stops_before_collection(system_info, tmp_path): + sag = tmp_path / "afid_sag.json" + sag.write_text("{}", encoding="utf-8") + + class _RejectingHub(_FakeHub): + def validate(self, afid_sag_path): + return {"status": "error", "error": {"message": "schema mismatch"}} + + fake_ep = SimpleNamespace(name="afse", load=lambda: _RejectingHub) + plugin = Mi4xxServiceabilityPlugin(system_info=system_info) + with ( + patch( + "nodescraper.plugins.serviceability.se_runner._entry_points_for_group", + return_value=[fake_ep], + ), + patch.object(MI4XXCollector, "collect_data") as collect_data, + ): + result = plugin.run( + collection=True, + analysis=True, + analysis_args=Mi4xxServiceabilityAnalyzerArgs(afid_sag_path=str(sag)), + ) + collect_data.assert_not_called() + assert result.status == ExecutionStatus.ERROR + assert "schema mismatch" in result.message + + def test_load_hub_from_entry_point_missing_raises(): with patch( "nodescraper.plugins.serviceability.se_runner._entry_points_for_group", diff --git a/test/unit/plugin/test_se_runner.py b/test/unit/plugin/test_se_runner.py index d355d814..4fd86a08 100644 --- a/test/unit/plugin/test_se_runner.py +++ b/test/unit/plugin/test_se_runner.py @@ -24,6 +24,7 @@ # ############################################################################### import json +import logging from pathlib import Path from types import SimpleNamespace from typing import Any @@ -195,6 +196,63 @@ def test_resolve_hub_events_payload_honors_prefer_rf_events_false(): assert payload[0]["location"] == EXAMPLE_EVENTS[0].serviceable_unit +def _rf_member(odata_id, *identifiers): + return {"@odata.id": odata_id, "Oem": {"AMD": {"AMDFieldIdentifiers": list(identifiers)}}} + + +_UNIT = {"@odata.id": "/redfish/v1/Chassis/Instinct_EAM_0"} + + +def test_resolve_hub_events_payload_waives_afid_without_serviceable_units(caplog): + from nodescraper.plugins.serviceability.se_runner import ( + resolve_hub_events_payload, + ) + + good = _rf_member("/entries/1", {"AFID": 11101, "ServiceableUnits": [_UNIT]}) + bad = _rf_member("/entries/2", {"AFID": 11045, "ServiceableUnits": []}) + with caplog.at_level(logging.WARNING): + payload = resolve_hub_events_payload( + afid_events=EXAMPLE_EVENTS, rf_events=[good, bad], prefer_rf_events=True + ) + assert payload == [good] + assert "Waiving AFID 11045 from /entries/2" in caplog.text + + +def test_resolve_hub_events_payload_keeps_valid_entries_on_mixed_member(caplog): + from nodescraper.plugins.serviceability.se_runner import ( + resolve_hub_events_payload, + ) + + member = _rf_member( + "/entries/3", + {"AFID": 11101, "ServiceableUnits": [_UNIT]}, + {"AFID": 11045}, + {"AFID": 11046, "ServiceableUnits": [{"Name": "no odata id"}]}, + ) + with caplog.at_level(logging.WARNING): + payload = resolve_hub_events_payload( + afid_events=EXAMPLE_EVENTS, rf_events=[member], prefer_rf_events=True + ) + identifiers = payload[0]["Oem"]["AMD"]["AMDFieldIdentifiers"] + assert [entry["AFID"] for entry in identifiers] == [11101] + assert len(member["Oem"]["AMD"]["AMDFieldIdentifiers"]) == 3 + assert "Waiving AFID 11045" in caplog.text + assert "Waiving AFID 11046" in caplog.text + + +def test_resolve_hub_events_payload_falls_back_to_afid_events_when_all_waived(): + from nodescraper.plugins.serviceability.se_runner import ( + resolve_hub_events_payload, + ) + + payload = resolve_hub_events_payload( + afid_events=EXAMPLE_EVENTS[:1], + rf_events=[_rf_member("/entries/4", {"AFID": 11045})], + prefer_rf_events=True, + ) + assert payload[0]["afid"] == EXAMPLE_EVENTS[0].afid + + def test_resolved_hub_options_explicit_fields_override_options_bag(): args = ServiceabilityAnalyzerArgs( hub_python_module="dummy.test.module", @@ -609,3 +667,95 @@ def test_mi3xx_analyzer_writes_serviceability_json(tmp_path, system_info): data.log_model(str(tmp_path)) payload = json.loads((tmp_path / "serviceability.json").read_text(encoding="utf-8")) assert payload["afid_events"][0]["afid"] == DUMMY_AFID_A + + +class _ValidatingHub: + name = "AFSE" + + def __init__(self, response=None, default_path=None): + self._response = response if response is not None else {"status": "ok"} + self._default_path = default_path + self.validated: list[str] = [] + + def validate(self, afid_sag_path): + self.validated.append(afid_sag_path) + return self._response + + def default_sag_path(self): + return self._default_path + + +def test_validate_afid_sag_with_hub_accepts_ok_status(tmp_path): + from nodescraper.plugins.serviceability.se_runner import validate_afid_sag_with_hub + + hub = _ValidatingHub() + assert validate_afid_sag_with_hub(hub, str(tmp_path / "sag.json")) is True + assert hub.validated == [str(tmp_path / "sag.json")] + + +def test_validate_afid_sag_with_hub_raises_on_error_status(): + from nodescraper.plugins.serviceability.se_runner import ( + HubRunError, + validate_afid_sag_with_hub, + ) + + hub = _ValidatingHub({"status": "error", "error": {"code": "E1", "message": "bad schema"}}) + with pytest.raises(HubRunError, match="AFSE SAG validation failed: bad schema"): + validate_afid_sag_with_hub(hub, "/tmp/sag.json") + + +def test_validate_afid_sag_with_hub_skips_hub_without_validate(): + from nodescraper.plugins.serviceability.se_runner import validate_afid_sag_with_hub + + assert validate_afid_sag_with_hub(SimpleNamespace(name="Other"), "/tmp/sag.json") is False + + +def test_resolve_runtime_afid_sag_path_prefers_configured_path(tmp_path): + from nodescraper.plugins.serviceability.se_runner import ( + resolve_runtime_afid_sag_path, + ) + + args = ServiceabilityAnalyzerArgs( + hub_entry_point="afse", afid_sag_path=str(tmp_path / "x.json") + ) + assert resolve_runtime_afid_sag_path(args) == str(tmp_path / "x.json") + + +def test_resolve_runtime_afid_sag_path_uses_hub_default(monkeypatch, tmp_path): + from nodescraper.plugins.serviceability import se_runner + + hub_default = str(tmp_path / "hub_default.json") + monkeypatch.setattr( + se_runner, + "load_hub_from_entry_point", + lambda name: _ValidatingHub(default_path=hub_default), + ) + args = ServiceabilityAnalyzerArgs(hub_entry_point="afse") + assert se_runner.resolve_runtime_afid_sag_path(args) == hub_default + + +def test_resolve_runtime_afid_sag_path_falls_back_to_builtin_default(monkeypatch): + from nodescraper.plugins.serviceability import se_runner + from nodescraper.plugins.serviceability.afid_sag_paths import DEFAULT_AFID_SAG_PATH + + monkeypatch.setattr( + se_runner, "load_hub_from_entry_point", lambda name: SimpleNamespace(name="AFSE") + ) + args = ServiceabilityAnalyzerArgs(hub_entry_point="afse") + assert se_runner.resolve_runtime_afid_sag_path(args) == DEFAULT_AFID_SAG_PATH + + +def test_run_entry_point_hub_stops_when_afse_rejects_sag(monkeypatch, tmp_path): + from nodescraper.plugins.serviceability import se_runner + + sag = tmp_path / "afid_sag.json" + sag.write_text("{}", encoding="utf-8") + hub = _ValidatingHub({"status": "error", "error": {"message": "schema mismatch"}}) + monkeypatch.setattr(se_runner, "load_hub_from_entry_point", lambda name: hub) + with pytest.raises(se_runner.HubRunError, match="schema mismatch"): + se_runner.run_entry_point_hub( + hub_entry_point="afse", + afid_events=EXAMPLE_EVENTS[:1], + afid_sag_path=str(sag), + ) + assert hub.validated == [str(sag.resolve())] diff --git a/test/unit/plugin/test_serviceability_recommendations_table.py b/test/unit/plugin/test_serviceability_recommendations_table.py index 12ad35b6..4616a704 100644 --- a/test/unit/plugin/test_serviceability_recommendations_table.py +++ b/test/unit/plugin/test_serviceability_recommendations_table.py @@ -110,7 +110,7 @@ def test_split_recommendation_rows_uses_hub_top(): assert additional[0].afid == DUMMY_AFID_B -def test_render_serviceability_recommendation_tables_splits_sections(): +def test_render_serviceability_recommendation_tables_prints_only_top_action(): block = ServiceabilityBlock( hub_top_results=[ _row( @@ -142,10 +142,9 @@ def test_render_serviceability_recommendation_tables_splits_sections(): output = render_serviceability_recommendation_tables(block) assert "Top Hub service action (rank 1)" in output - assert "Additional Hub service actions (rank 2)" in output assert f"{DUMMY_AFID_A}: Contact Support" in output - assert f"{DUMMY_AFID_B}: Update FW" in output - assert output.index("Top Hub service action") < output.index("Additional Hub service actions") + assert "Additional Hub service actions" not in output + assert f"{DUMMY_AFID_B}: Update FW" not in output def test_build_top_service_actions_includes_tied_entries():