diff --git a/ci/README.md b/ci/README.md index 9299c9f..e6bd5e6 100644 --- a/ci/README.md +++ b/ci/README.md @@ -38,6 +38,10 @@ The root README shows totals for integration failures, validator check errors (`ERROR !!`), and validator check FAILs (`FAIL`), plus separate per-fabric columns. It highlights fabrics with check errors and links to the affected checks. Each fabric's README shows its source pipeline, commit, run date, and result details. +Both summaries include source and target version columns parsed from the log: +the current APIC version and the selected target APIC version. If no firmware +was detected, the target is explicitly not selected. Missing logs show versions +as unavailable; missing or conflicting version headers show unknown values. `error.txt` is reserved for integration execution or collection failures and includes this provenance even when a repeated failure has identical diagnostics. An unavailable or unrecognized validator summary is shown explicitly and is diff --git a/ci/publish_integration_results.py b/ci/publish_integration_results.py index 8ff151f..3a07831 100644 --- a/ci/publish_integration_results.py +++ b/ci/publish_integration_results.py @@ -66,10 +66,23 @@ def validate_source(manifest, environment): def validator_result(report): """Read the validator's summary; keep check errors distinct from FAIL findings.""" result = {"status": "not_available", "error_count": None, "fail_count": None, - "error_checks": [], "fail_checks": []} + "error_checks": [], "fail_checks": [], "source_version": None, + "target_version": None, "target_version_status": "not_available"} if not report.is_file(): return result text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", report.read_text(encoding="utf-8", errors="replace")) + # Read the run header, not firmware candidates or versions in check details. + header = re.split(r"^\[Check\s+\d+/\d+\]", text, maxsplit=1, flags=re.MULTILINE)[0] + version = r"(\d+\.\d+\([0-9A-Za-z_.-]+\))" + sources = re.findall(r"^Current APIC Version\.\.\.\s*" + version + r"\s*$", header, re.MULTILINE) + targets = re.findall(r'^You have chosen version "' + version + r'"\s*$', header, re.MULTILINE) + no_firmware = bool(re.search(r"^No Firmware Detected!", header, re.MULTILINE)) + result["source_version"] = sources[0] if len(sources) == 1 else None + result["target_version_status"] = "unknown" + if len(targets) == 1 and not no_firmware: + result.update(target_version=targets[0], target_version_status="selected") + elif not targets and no_firmware: + result["target_version_status"] = "not_selected" lines = text.splitlines() starts = [index for index, line in enumerate(lines) if re.match(r"^\[Check\s+\d+/\d+\]", line)] @@ -115,15 +128,26 @@ def count_link(result, category, folder=""): return str(count) +def version_cell(result, category): + value = result[category + "_version"] + if value: + return markdown_label(value) + if result["status"] == "not_available": + return "Not available" + if category == "target" and result["target_version_status"] == "not_selected": + return "Not selected (no firmware)" + return "Unknown" + + def fabric_readme(fabric, manifest, errors, result, links): lines = [ "# " + markdown_label(fabric["name"]), "", "- GitLab pipeline: {}".format(manifest["source_pipeline_id"]), "- Script commit: `{}`".format(manifest["source_commit"]), "- Run date: {}".format(manifest.get("created_at", "")), "", - "| Integration / collection | Validator check errors | Validator check FAILs |", - "| --- | --- | --- |", - "| {} | {} | {} |".format("Failed" if errors else "Completed", count_link(result, "error"), count_link(result, "fail")), "", + "| Source version | Target version | Integration / collection | Validator check errors | Validator check FAILs |", + "| --- | --- | --- | --- | --- |", + "| {} | {} | {} | {} | {} |".format(version_cell(result, "source"), version_cell(result, "target"), "Failed" if errors else "Completed", count_link(result, "error"), count_link(result, "fail")), "", "Files: " + " · ".join(links), "", ] if errors: @@ -186,8 +210,9 @@ def prepare_snapshot(source, destination, environment): (output / "README.md").write_text(fabric_readme(fabric, manifest, errors, result, local_links), encoding="utf-8") links.insert(0, "[summary]({}/README.md)".format(quote(folder))) rows.append( - "| [{}]({}/README.md) | {} | {} | {} | {} |".format( + "| [{}]({}/README.md) | {} | {} | {} | {} | {} | {} |".format( markdown_label(fabric["name"]), quote(folder), + version_cell(result, "source"), version_cell(result, "target"), "Failed" if errors else "Completed", count_link(result, "error", folder), count_link(result, "fail", folder), " · ".join(links), @@ -223,6 +248,10 @@ def prepare_snapshot(source, destination, environment): "Validator check errors (`ERROR !!`) and validator check FAILs (`FAIL`) are shown separately; " "completed does not mean upgrade-ready. Missing or unrecognized summaries are not counted as zero.", "", + "Source version is the current APIC version recorded in the validator log. " + "Target version is the selected APIC version recorded in that same run; " + "no firmware means no target was selected. Versions are unavailable when no log was collected.", + "", ] if validator_errors: readme.extend(["### Fabrics with validator check errors", ""]) @@ -232,8 +261,8 @@ def prepare_snapshot(source, destination, environment): for fabric in validator_errors) readme.append("") readme.extend([ - "| Fabric | Integration / collection | Validator check errors | Validator check FAILs | Files |", - "| --- | --- | --- | --- | --- |", + "| Fabric | Source version | Target version | Integration / collection | Validator check errors | Validator check FAILs | Files |", + "| --- | --- | --- | --- | --- | --- | --- |", ]) readme.extend(rows) readme.extend( diff --git a/ci/tests/test_publish_integration_results.py b/ci/tests/test_publish_integration_results.py index 7554038..5eefa3e 100644 --- a/ci/tests/test_publish_integration_results.py +++ b/ci/tests/test_publish_integration_results.py @@ -120,6 +120,50 @@ def test_unknown_validator_summary_does_not_claim_zero_errors(self): snapshot = self.prepare() self.assertIn("Unknown", (snapshot / "fabric-a/README.md").read_text()) + def test_versions_use_current_apic_and_selected_target_from_run_header(self): + report = self.source / "fabric-a/results.log" + original = report.read_bytes() + report.write_bytes( + b"Script Version v4.3.0\n" + b"\x1b[32mCurrent APIC Version...6.1(5e)\x1b[0m\n" + b"Lowest Switch Version...6.0(7e)\n" + b"[1]: aci-apic-dk9.4.2.7u.bin\n" + b'You have chosen version "5.3(2a)"\n' + original + ) + snapshot = self.prepare() + result = json.loads((snapshot / "manifest.json").read_text())["fabrics"][0]["validator_result"] + self.assertEqual(result["source_version"], "6.1(5e)") + self.assertEqual(result["target_version"], "5.3(2a)") + self.assertEqual(result["target_version_status"], "selected") + for readme in (snapshot / "README.md", snapshot / "fabric-a/README.md"): + self.assertIn("| 6.1(5e) | 5.3(2a) | Completed |", readme.read_text()) + self.assertEqual((snapshot / "fabric-a/results.log").read_bytes(), report.read_bytes()) + + def test_versions_distinguish_no_firmware_from_missing_or_conflicting_headers(self): + report = self.source / "fabric-a/results.log" + summary = report.read_text() + report.write_text("Current APIC Version...1.2(3.456)\n" + "No Firmware Detected! Please Upload APIC Firmware and re-run the script.\n" + summary) + snapshot = self.prepare() + result = publisher.validator_result(report) + self.assertEqual(result["source_version"], "1.2(3.456)") + self.assertIsNone(result["target_version"]) + self.assertEqual(result["target_version_status"], "not_selected") + self.assertIn("Not selected (no firmware)", (snapshot / "README.md").read_text()) + for header in ( + "", + 'You have chosen version "6.0(9e)"\nYou have chosen version "6.1(6g)"\n', + 'You have chosen version "6.0(9e)"\nNo Firmware Detected!\n', + ): + report.write_text(header + summary) + result = publisher.validator_result(report) + self.assertEqual(result["target_version_status"], "unknown") + self.assertEqual(publisher.version_cell(result, "target"), "Unknown") + report.unlink() + result = publisher.validator_result(report) + self.assertEqual(publisher.version_cell(result, "source"), "Not available") + self.assertEqual(publisher.version_cell(result, "target"), "Not available") + def test_identical_connection_failure_has_new_per_fabric_provenance(self): for filename in publisher.FILES: (self.source / "fabric-a" / filename).unlink()