Add unit tests for ironic.py pure helpers#2342
Conversation
Cover the helpers in osism/tasks/conductor/ironic.py that can be tested without the full sync orchestration (issue #2226): - _derive_as_from_hostname_yrzn - _get_metalbox_primary_ip4_fallback - _get_metalbox_primary_ip4 - _render_templates - _prepare_node_attributes - _prettify_for_display Collaborators are patched at their ironic.py import sites. The NetBox client is replaced via osism.utils.nb, which the helpers' local "from osism import utils" resolves to, matching the existing test_netbox and sonic test conventions. Note: the issue's example asserting _derive_as_from_hostname_yrzn("comp-nw-22-3-7-1") == "4200143307" does not match the implementation, which pads rack/server and yields "4200140703" (consistent with the function's own docstring example). The test encodes the verified value. Assisted-by: Claude:anthropic-opus-4-8 Signed-off-by: Christian Berendt <berendt@osism.tech>
75a2e60 to
1ce607c
Compare
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- The local
_YRZN_MAPPINGtest fixture duplicates theSUPPORTED_IPA_TYPESstructure and can silently diverge from the real mapping over time; consider deriving it fromSUPPORTED_IPA_TYPESat runtime (e.g.SUPPORTED_IPA_TYPES['yrzn001'].copy()or similar) and only overriding what needs to be controlled for the tests.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The local `_YRZN_MAPPING` test fixture duplicates the `SUPPORTED_IPA_TYPES` structure and can silently diverge from the real mapping over time; consider deriving it from `SUPPORTED_IPA_TYPES` at runtime (e.g. `SUPPORTED_IPA_TYPES['yrzn001'].copy()` or similar) and only overriding what needs to be controlled for the tests.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
ideaship
left a comment
There was a problem hiding this comment.
Production bug exposed by the coverage gap (osism/tasks/conductor/utils.py)
_prepare_node_attributes() returns a 2-tuple (node_attributes, template_vars) (ironic.py:332). Two callers in utils.py still treat the return value as a dict:
_get_conductor_redfish_credentials(utils.py:223, thenutils.py:227)_get_conductor_redfish_address(utils.py:255, thenutils.py:259)
Both do node_attributes = _prepare_node_attributes(...) and then if "driver_info" in node_attributes and node_attributes.get("driver") == "redfish":. Because node_attributes is now a tuple, "driver_info" in node_attributes is a tuple-membership test — is the string equal to one of the two dict elements? — which is always False. The if short-circuits before .get() is ever reached, so no exception is raised and nothing is logged: both functions silently return None, None / None regardless of the device's actual configuration.
What this actually breaks. These two helpers are the conductor-configuration fallback for Redfish, and they are the only source of credentials for the osism redfish list <hostname> <EthernetInterfaces|NetworkAdapters|NetworkDeviceFunctions> command. All three internal call sites call get_redfish_connection(hostname, ignore_ssl_errors=True) with no username/password, so:
if not username or not password:is always true →_get_conductor_redfish_credentialsis invoked on every call to that command, andget_redfish_connectionnever reads credentials from Ironicdriver_info(it only pullsredfish_addressfrom there). With the bug, credentials always come backNone, None, the BMC connection is built withSessionOrBasicAuth(username=None, password=None), and against any BMC that requires authentication the Redfish call fails, the exception is swallowed inredfish.py, and the command returns an empty list / "Could not establish Redfish connection" — with no hint that credentials were dropped. In practice the conductor-credential fallback is dead.- The address fallback (
_get_conductor_redfish_address) likewise returnsNone, sobase_urlsilently stays at the defaulthttps://{hostname}instead of the conductor-configured address. Lower impact (it degrades rather than hard-fails), but still wrong.
The tuple return was introduced in 0e66cb8, which changed ironic.py but never updated these two utils.py callers; test_utils.py has zero coverage of either function, so nothing in the suite catches it. This test-only PR cannot catch it by design — it never exercises the utils.py consumers — so flagging it for a separate fix.
Fix: unpack at both call sites — node_attributes, _ = _prepare_node_attributes(...) — and add a regression test for _get_conductor_redfish_credentials and _get_conductor_redfish_address.
| assert tvars["remote_board_password"] == "password" | ||
|
|
||
|
|
||
| def test_prepare_template_vars_honor_node_secrets(prep): |
There was a problem hiding this comment.
_prepare_node_attributes has an explicit guard at ironic.py:213-215: node_secrets = device.custom_fields.get("secrets", {}) followed by if node_secrets is None: node_secrets = {}. That None branch handles a real NetBox state — the secrets custom field present but explicitly null, distinct from absent — and no test here passes custom_fields={"secrets": None}. Coverage shows it's the only line in the scoped helper region left uncovered. Please add a case with custom_fields={"secrets": None} to exercise it.
| prep.deep_merge.assert_not_called() | ||
|
|
||
|
|
||
| def test_prepare_decrypts_and_merges_config_context_ironic_parameters(prep): |
There was a problem hiding this comment.
These three "merge" tests (test_prepare_decrypts_and_merges_config_context_ironic_parameters, ..._custom_field_ironic_parameters, ..._merges_config_context_before_custom_field) patch deep_merge to a no-op MagicMock and only assert the overlay it was called with (call_args.args[1]) plus call order/count. They never assert the base argument (args[0]) is the node_attributes accumulator, nor that the returned attrs reflects a merged result. Since node_attributes is mutated in place and returned as attrs, a future wiring regression — base/overlay transposed, or merging into a stray copy — would pass these tests undetected. Today's wiring is correct, so this is a coverage gap, not a current bug. Suggest one integration-style case using the real deep_merge: a non-trivial base with config_context={"ironic_parameters": {"some_new_key": "v"}}, then assert the returned attrs contains both the original key and some_new_key.
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| # The yrzn001 mapping mirrored from the production SUPPORTED_IPA_TYPES so the |
There was a problem hiding this comment.
No action needed — just flagging the trade-off: mirroring SUPPORTED_IPA_TYPES into a local _YRZN_MAPPING and patching the production constant with the copy keeps these tests isolated from future edits to the real dict, but it also means a production rename or typo in SUPPORTED_IPA_TYPES would not be caught here — the tests run against the frozen mirror.
Closes #2226.
Adds
tests/unit/tasks/conductor/test_ironic_helpers.pycovering the purehelpers and the
_prepare_node_attributesbuilder inosism/tasks/conductor/ironic.py— everything testable without the fullsync orchestration (the sync entry points stay in the companion sub-issue).
Change set
A single new test module (no production code touched), organized by helper:
_derive_as_from_hostname_yrzn— storage vs. non-storage type digit,< 5parts →None, single-digit padding, already-two-digit values leftalone.
_get_metalbox_primary_ip4_fallback— invalid YAML, non-list setting,non-dict element skipped,
tagstripped +role="metalbox"added (assertedvia captured
filterkwargs), first/second metalbox IP selection, and thetwo no-IP / no-metalbox warning paths.
_get_metalbox_primary_ip4— no OOB IP (fallback not called), subnetmatch, no-match → fallback, matching subnet but no
primary_ip4→ earlyNone(no fallback), match on the second interface, and IPv4/IPv6 mixing._render_templates— flat/nested dict, nested list, list of dicts,non-template and non-string values untouched, multiple vars, in-place
mutation returning
None._prepare_node_attributes— base/config-context/custom-field mergingand order, driver pruning, template variables (board creds, OOB address,
ironic_osism_*propagation),osism-ipa-type=yrzn001kernel enrichment(frr params, metalbox, AS fallback, unknown type),
skip_kernel_params,extra_kernel_params,driver_infopersistence,extraserialization, andthe returned tuple.
_prettify_for_display— JSON parsing inextra, non-JSON leftuntouched, deep copy with/without
extra.Mocking notes
deep_decrypt,deep_merge,get_vault,get_device_oob_ip,SUPPORTED_IPA_TYPES,_derive_as_from_hostname_yrzn,_get_metalbox_primary_ip4) are patched atosism.tasks.conductor.ironic.*.osism.utils.nb(withcreate=True), whichis what the helpers' local
from osism import utilsresolves to — matchingthe existing
test_netbox.pyand sonic test fixtures.ironic.pyhas nomodule-level
utilsattribute (it imports it asosism_utils), so theliteral
osism.tasks.conductor.ironic.utils.nbpath from the issue is not avalid patch target;
osism.utils.nbis the established equivalent.deep_decrypt/deep_mergeare stubbed as no-ops; their real behavior iscovered by the conductor.utils tests, so the merge cases assert call
identity/order rather than merged output.
Deviation from the issue
The issue lists
_derive_as_from_hostname_yrzn("comp-nw-22-3-7-1")→"4200143307". The implementation pads rack/server and produces"4200140703"(matches the function's own docstring examplestor-nw-22-60-59-6→4200155960). The test encodes the verified value"4200140703"; the same case (non-storage type 4, single-digit padding) isstill covered.
Verification
black --check,flake8— pass on the new file.pytest/ coverage — left to thepython-osism-unit-testsZuul job.AI-assisted: Claude Code