Skip to content

Unit tests for osism/tasks/conductor/ironic.py — sync orchestrators #2227

@berendt

Description

@berendt

Background

Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 3 (#2199). Companion to the ironic helpers issue: covers the four sync orchestrators in osism/tasks/conductor/ironic.py. These are large state-machine functions that drive transitions between Ironic provision states based on NetBox device data. Tests focus on the state transitions and branch coverage, not on exact push_task_output strings.

Scope

Add tests/unit/tasks/conductor/test_ironic_sync.py covering the four sync entry points in osism/tasks/conductor/ironic.py.

Test targets

_sync_ironic_device(request_id, device, node_attributes, ports_attributes, adopt, force)ironic.py:354

Patch osism.tasks.conductor.ironic.osism_utils and osism.tasks.conductor.ironic.openstack (the module-level imports). Stub deep_compare so it can populate the node_updates dict on demand.

Node creation path (baremetal_node_show returns None)

  • Creates node via baremetal_node_create with automated_clean=False
  • target_raid_config popped from node_attributes before create; if present, baremetal_node_set_target_raid_config invoked with (uuid, target_raid_config)
  • set_target_raid_config returns (False, "error") → raises Exception containing "target_raid_config"

Node update path (baremetal_node_show returns existing node)

  • deep_compare populates node_updates={...}baremetal_node_update called with full node_attributes
  • Driver password key popped from node_updates["driver_info"] before evaluating change (verify password key is computed via driver_params[node_attributes["driver"]]["password"])
  • All driver_info updates were just the password → driver_info removed entirely from node_updates; if no other updates remain and force=Falsebaremetal_node_update not called
  • force=True and no updates → still calls baremetal_node_update
  • target_raid_config in node_updatesbaremetal_node_set_target_raid_config called

Port reconciliation

  • node_ports initially [port1, port2]; ports_attributes has port1's MAC → port2 deleted via baremetal_port_delete
  • New MAC in ports_attributes not in node_portsbaremetal_port_create called
  • MAC comparison is case-insensitive (.upper())

State transitions (validation + provisioning)

  • node_validation["management"].result=False → push warning, function returns
  • Management OK, provision_state="enroll" → transitioned to manage then waited for manageable
  • Management OK, provision_state="clean failed" → transitioned to manage
  • After moving to manageable and power_state != "power off" and not adoption → baremetal_node_set_power_state(uuid, "power off", wait=True, timeout=300)
  • Validation boot.result=False and provision_state="available" → demoted to manage
  • Validation OK, adopt path: provision_state="manageable" and is_adoption=Trueset_provision_state("adopt") + wait for "active"
  • Adopt path with provision_state="available" → first transitions to manage ("Prepare adoption...")
  • Validation OK, normal path: provision_state="manageable" → set boot device, transition to provide, wait for "available"
  • automated_clean=True after available transition → updated back to True
  • set_boot_device raises → caught, logged, transition continues
  • is_adoption derived from adopt or device.custom_fields["provision_state"] == "active"

_sync_ironic_device_dry_run(request_id, device, node_attributes, ports_attributes, adopt, force, template_vars)ironic.py:574

Patch osism_utils.push_task_output and capture all messages.

Secret masking

  • template_vars contains a key with password / secret / ironic_osism_* and a string value → that value collected into secret_values, then passed to mask_secrets (verify the call args)
  • Non-string values not collected
  • Unrelated keys not collected

Create branch (baremetal_node_show returns None)

  • Push contains "Would CREATE baremetal node" and "Would CREATE port with MAC" for each port
  • adopt=True → adopt message pushed; otherwise available message
  • device.custom_fields["provision_state"]=="active" and adopt=False → still adopt message

Update branch (baremetal_node_show returns existing node)

  • node_updates populated → push "Would UPDATE baremetal node"
  • No updates and force=False → push "no update needed"
  • Existing port matched → not in delete list; missing port → "Would CREATE port"; extra port → "Would DELETE port"
  • Always pushes the current provision_state

sync_ironic(request_id, get_ironic_parameters, node_name=None, adopt=False, force=False, dry_run=False, skip_kernel_params=None, extra_kernel_params=None)ironic.py:685

Patch osism_utils, openstack, netbox (the osism.tasks.netbox module), _prepare_node_attributes, _sync_ironic_device, _sync_ironic_device_dry_run, osism_utils.create_redlock.

  • dry_run=True → prefix "[DRY RUN] " used in messages, lock not acquired, calls _sync_ironic_device_dry_run
  • dry_run=False, lock acquired → calls _sync_ironic_device, lock released even if it raises (verify release() in finally)
  • Lock not acquired → message pushed, sync function not called for that device
  • NetBox API not reachable (osism_utils.nb.status() raises) → error pushed, finish_task_output(rc=1), returns
  • Ironic API not reachable (baremetal.nodes(limit=1) raises) → error pushed, finish_task_output(rc=1), returns
  • node_name set, no matching device in NetBox → "Node ... not found in NetBox" pushed, returns rc=1
  • Stale Ironic node (not in NetBox, eligible state and power_state) → deleted (dry_run=False) or "Would delete..." pushed (dry_run=True)
  • provision_state="clean failed" stale node → moved to manageable first, then ports + node deleted
  • Stale node not eligible (e.g. active) → "Cannot remove..." pushed
  • skip_kernel_params / extra_kernel_params propagated into _prepare_node_attributes
  • ports_attributes built from netbox.get_interfaces_by_device filtered by enabled and not mgmt_only and mac_address

sync_netbox_from_ironic(request_id, node_name=None, netbox_filter=None)ironic.py:881

Patch osism_utils.nb, osism_utils.secondary_nb_list, osism_utils.get_openstack_connection, _matches_netbox_filter, openstack.baremetal_node_list, and netbox.set_provision_state / set_power_state / set_maintenance.

Filter behavior (netbox_filter set)

  • Primary matches and is reachable → included; set_* called with netbox_filter=...
  • Primary not reachable but secondary matches and is reachable → continues with secondary
  • No NetBox instance matches filter → error pushed, rc=1, returns
  • All filtered instances unreachable → error pushed, rc=1, returns

No filter

  • Primary unreachable → error pushed, returns rc=1
  • Primary reachable, all secondaries reachable → reachable_secondaries = full list
  • Primary reachable, one secondary unreachable → that secondary skipped
  • No secondaries configured → message includes "to NetBox" (no "(including secondaries)")

Node sync

  • node_name set, no matching node in Ironic → "not found in Ironic" pushed, rc=1
  • Each node calls set_provision_state, set_power_state, set_maintenance with secondary_nb_list=reachable_secondaries
  • Any set_* returning False → device added to failed_devices; final warning lists them
  • All succeed → no warning
  • Ironic API unreachable → error pushed, rc=1

Mocking hints

  • These functions are heavy on push_task_output calls. Tests should accept any reasonable string content — assert on substrings (e.g. assert any("Would CREATE" in call.args[1] for call in push_task_output.mock_calls)).
  • Build node dicts inline as plain dicts since Ironic returns dict-like objects (node["uuid"], node["provision_state"], etc.).
  • baremetal_node_validate returns an object with .result and .reason attributes — use SimpleNamespace.
  • Lock fixture: mocker.patch("osism.tasks.conductor.ironic.osism_utils.create_redlock", return_value=MagicMock(acquire=MagicMock(return_value=True), release=MagicMock())).
  • For port reconciliation, node_ports is mutated in-place during the loop — keep mock data simple (one or two ports per case).

Definition of Done

  • tests/unit/tasks/conductor/test_ironic_sync.py created
  • All listed cases covered
  • pytest --cov=osism.tasks.conductor.ironic for the targeted functions ≥ 80 % (some defensive except branches and message-only push paths may stay lower)
  • pipenv run pytest tests/unit/tasks/conductor/test_ironic_sync.py passes locally
  • flake8, mypy, python-black remain green
  • Zuul job python-osism-unit-tests passes

Dependencies

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions