From 8209fe35ac82cdb5ca1fe3c95db16daf8eb3fbb5 Mon Sep 17 00:00:00 2001 From: gent79reid Date: Fri, 29 May 2026 14:21:56 +0800 Subject: [PATCH] feat: add Spine/Leaf certificate expiry validation check Adds switch_cert_validation, a new pre-upgrade check that verifies the SSL certificates installed on spine/leaf switches via the pkiFabricNodeSSLCertificate class. Certificates with an expired validityNotAfter are flagged as FAIL_O along with the matching pod, node id, node name, role, and expiry date. References Field Notice FN72339. Registered in CheckManager.checks, documented in validations.md, and covered by parameterised pytest cases (expired-cert and all-valid). --- aci-preupgrade-validation-script.py | 73 +++++++++++++++++ docs/docs/validations.md | 9 ++- .../switch_cert_validation/fabricNode.json | 38 +++++++++ .../pkiFabricNodeSSLCertificate_negative.json | 28 +++++++ .../pkiFabricNodeSSLCertificate_positive.json | 54 +++++++++++++ .../test_switch_cert_validation.py | 80 +++++++++++++++++++ 6 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 tests/checks/switch_cert_validation/fabricNode.json create mode 100644 tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json create mode 100644 tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json create mode 100644 tests/checks/switch_cert_validation/test_switch_cert_validation.py diff --git a/aci-preupgrade-validation-script.py b/aci-preupgrade-validation-script.py index b420eea..3357832 100644 --- a/aci-preupgrade-validation-script.py +++ b/aci-preupgrade-validation-script.py @@ -4120,6 +4120,78 @@ def apic_ca_cert_validation(**kwargs): return Result(result=result, headers=headers, data=data, recommended_action=recommended_action, doc_url=doc_url) +@check_wrapper(check_title="Spine/Leaf Cert Validation") +def switch_cert_validation(fabric_nodes, **kwargs): + result = FAIL_O + headers = ["Pod-ID", "Node-ID", "Node Name", "Role", "Expiry Date"] + data = [] + recommended_action = "Renew the expired node SSL certificate before proceeding with the upgrade." + + if not fabric_nodes: + return Result(result=PASS) + + switch_nodes = {} + for fn in fabric_nodes: + fn_attrs = fn.get('fabricNode', {}).get('attributes', {}) + role = fn_attrs.get('role') + if role in ['spine', 'leaf']: + node_id = fn_attrs.get('id') + if node_id: + switch_nodes[node_id] = fn_attrs + + if not switch_nodes: + return Result(result=PASS) + + pki_certs = icurl('class', 'pkiFabricNodeSSLCertificate.json') + if not pki_certs: + return Result(result=PASS) + + for pki_cert in pki_certs: + attrs = pki_cert.get('pkiFabricNodeSSLCertificate', {}).get('attributes', {}) + node_id = attrs.get('nodeId') + if not node_id or node_id not in switch_nodes: + continue + + node_info = switch_nodes[node_id] + node_name = node_info.get('name', 'N/A') + role = node_info.get('role', 'N/A') + dn_match = re.search(node_regex, node_info.get('dn', '')) + pod_id = dn_match.group('pod') if dn_match else 'N/A' + + not_after_str = attrs.get('validityNotAfter', '') + if not not_after_str: + log.debug("node %s: empty validityNotAfter, skipping", node_id) + continue + + try: + expiry_dt = datetime.strptime(not_after_str[:19], "%Y-%m-%dT%H:%M:%S") + except ValueError: + log.debug("node %s: cannot parse validityNotAfter=%r, skipping", + node_id, not_after_str) + continue + + if expiry_dt > datetime.utcnow(): + continue + + data.append([ + pod_id, + node_id, + node_name, + role, + not_after_str, + ]) + + if not data: + result = PASS + + return Result( + result=result, + headers=headers, + data=data, + recommended_action=recommended_action, + ) + + @check_wrapper(check_title="FabricDomain Name") def fabricdomain_name_check(cversion, tversion, fabric_nodes, **kwargs): result = FAIL_O @@ -6432,6 +6504,7 @@ class CheckManager: fabric_link_redundancy_check, apic_downgrade_compat_warning_check, svccore_excessive_data_check, + switch_cert_validation, # Faults apic_disk_space_faults_check, diff --git a/docs/docs/validations.md b/docs/docs/validations.md index 64d5955..9242906 100644 --- a/docs/docs/validations.md +++ b/docs/docs/validations.md @@ -38,6 +38,7 @@ Items | This Script [APIC Database Size][g18] | :white_check_mark: | :no_entry_sign: [APIC downgrade compatibility when crossing 6.2 release][g19]| :white_check_mark: | :no_entry_sign: [Svccore Excessive Data Check][g20] | :white_check_mark: | :no_entry_sign: +[Spine/Leaf Cert Validation][g21] | :white_check_mark: | :no_entry_sign: [g1]: #compatibility-target-aci-version [g2]: #compatibility-cimc-version @@ -59,6 +60,7 @@ Items | This Script [g18]: #apic-database-size [g19]: #apic-downgrade-compatibility-when-crossing-62-release [g20]: #svccore-excessive-data-check +[g21]: #spineleaf-cert-validation ### Fault Checks Items | Faults | This Script | APIC built-in @@ -2785,6 +2787,11 @@ Administrators may be unable to access or operate the APIC GUI, potentially impa This check will verify the count of the `svccoreCtrlr` Managed Object and raise and alarm with the bug if object count found more than 240. Remove the content or objects of `svccoreCtrlr` or `svccoreNode`. Contact Cisco TAC or upgrade to a release containing the fix for CSCws84232 before proceeding with an upgrade. +### Spine/Leaf Cert Validation + +The script checks the expiration date of SSL certificates on spine and leaf switches (nodes with roles `spine` or `leaf`) via the `pkiFabricNodeSSLCertificate` class. If any certificate has already expired, it will be highlighted. Expired node certificates can prevent switches from successfully joining the fabric or communicating securely with the APIC controllers. + + [0]: https://github.com/datacenter/ACI-Pre-Upgrade-Validation-Script [1]: https://www.cisco.com/c/dam/en/us/td/docs/Website/datacenter/apicmatrix/index.html [2]: https://www.cisco.com/c/en/us/support/switches/nexus-9000-series-switches/products-release-notes-list.html @@ -2854,4 +2861,4 @@ This check will verify the count of the `svccoreCtrlr` Managed Object and raise [66]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwr66848 [67]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwh80837 [68]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwd40071 -[69]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCws84232 \ No newline at end of file +[69]: https://bst.cloudapps.cisco.com/bugsearch/bug/CSCws84232 diff --git a/tests/checks/switch_cert_validation/fabricNode.json b/tests/checks/switch_cert_validation/fabricNode.json new file mode 100644 index 0000000..5faa367 --- /dev/null +++ b/tests/checks/switch_cert_validation/fabricNode.json @@ -0,0 +1,38 @@ +[ + { + "fabricNode": { + "attributes": { + "dn": "topology/pod-1/node-1", + "fabricSt": "commissioned", + "id": "1", + "name": "apic1", + "role": "controller", + "nodeType": "unspecified" + } + } + }, + { + "fabricNode": { + "attributes": { + "dn": "topology/pod-1/node-101", + "fabricSt": "active", + "id": "101", + "name": "leaf101", + "role": "leaf", + "nodeType": "unspecified" + } + } + }, + { + "fabricNode": { + "attributes": { + "dn": "topology/pod-1/node-102", + "fabricSt": "active", + "id": "102", + "name": "spine102", + "role": "spine", + "nodeType": "unspecified" + } + } + } +] diff --git a/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json new file mode 100644 index 0000000..e860df7 --- /dev/null +++ b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_negative.json @@ -0,0 +1,28 @@ +[ + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "101", + "validCertificate": "yes", + "validityNotAfter": "2035-05-26T19:19:27.000+00:00", + "validityNotBefore": "2015-05-26T19:19:27.000+00:00", + "message": "", + "subject": "/CN=SAL1948U33K", + "serialNumber": "SAL1948U33K" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "102", + "validCertificate": "yes", + "validityNotAfter": "2035-05-26T19:19:27.000+00:00", + "validityNotBefore": "2015-05-26T19:19:27.000+00:00", + "message": "", + "subject": "/CN=SAL1948U35D", + "serialNumber": "SAL1948U35D" + } + } + } +] diff --git a/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json new file mode 100644 index 0000000..c13901b --- /dev/null +++ b/tests/checks/switch_cert_validation/pkiFabricNodeSSLCertificate_positive.json @@ -0,0 +1,54 @@ +[ + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "1", + "validCertificate": "yes", + "validityNotAfter": "2020-01-01T00:00:00.000+00:00", + "validityNotBefore": "2010-01-01T00:00:00.000+00:00", + "message": "", + "subject": "/CN=APIC1", + "serialNumber": "APIC1SERIAL" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "101", + "validCertificate": "no", + "validityNotAfter": "2099-05-26T19:19:27.000+00:00", + "validityNotBefore": "2019-05-26T19:19:27.000+00:00", + "message": "Failed to parse the subject line as a valid ACI fabric certificate AND invalid serial", + "subject": "/CN=SAL1948U33K", + "serialNumber": "SAL1948U33K" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "102", + "validCertificate": "yes", + "validityNotAfter": "2020-01-01T00:00:00.000+00:00", + "validityNotBefore": "2010-01-01T00:00:00.000+00:00", + "message": "", + "subject": "/CN=SAL1948U35D", + "serialNumber": "SAL1948U35D" + } + } + }, + { + "pkiFabricNodeSSLCertificate": { + "attributes": { + "nodeId": "999", + "validCertificate": "yes", + "validityNotAfter": "2020-01-01T00:00:00.000+00:00", + "validityNotBefore": "2010-01-01T00:00:00.000+00:00", + "message": "", + "subject": "/CN=GHOST", + "serialNumber": "GHOST" + } + } + } +] diff --git a/tests/checks/switch_cert_validation/test_switch_cert_validation.py b/tests/checks/switch_cert_validation/test_switch_cert_validation.py new file mode 100644 index 0000000..f446db6 --- /dev/null +++ b/tests/checks/switch_cert_validation/test_switch_cert_validation.py @@ -0,0 +1,80 @@ +import os +import pytest +import logging +import importlib +from helpers.utils import read_data + +script = importlib.import_module("aci-preupgrade-validation-script") + +log = logging.getLogger(__name__) +dir = os.path.dirname(os.path.abspath(__file__)) + +test_function = "switch_cert_validation" + +# icurl queries +pki_certs_api = "pkiFabricNodeSSLCertificate.json" + + +@pytest.mark.parametrize( + "icurl_outputs, fabric_nodes, expected_result, expected_data", + [ + # FAIL - Only expired certs on spine/leaf are flagged. + # The positive fixture contains 4 certs: an expired APIC controller cert + # (must be ignored - controllers are out of scope), a valid leaf cert + # (not expired), an expired spine cert (must be flagged), and a cert for + # an unknown nodeId (must be ignored). + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_positive.json")}, + read_data(dir, "fabricNode.json"), + script.FAIL_O, + [ + [ + "1", + "102", + "spine102", + "spine", + "2020-01-01T00:00:00.000+00:00", + ], + ], + ), + # PASS - All certificates are valid and unexpired. + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_negative.json")}, + read_data(dir, "fabricNode.json"), + script.PASS, + [], + ), + # PASS - pkiFabricNodeSSLCertificate class returns no objects. + ( + {pki_certs_api: []}, + read_data(dir, "fabricNode.json"), + script.PASS, + [], + ), + # PASS - fabric_nodes contains controllers only; even with an expired + # controller cert in the response, the check must return PASS because + # it is scoped to spine/leaf nodes only. + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_positive.json")}, + [ + fn for fn in read_data(dir, "fabricNode.json") + if fn["fabricNode"]["attributes"].get("role") == "controller" + ], + script.PASS, + [], + ), + # PASS - fabric_nodes is empty (no APIC data at all). + ( + {pki_certs_api: read_data(dir, "pkiFabricNodeSSLCertificate_positive.json")}, + [], + script.PASS, + [], + ), + ], +) +def test_logic(run_check, mock_icurl, fabric_nodes, expected_result, expected_data): + result = run_check( + fabric_nodes=fabric_nodes, + ) + assert result.result == expected_result + assert result.data == expected_data