Skip to content

Unit tests for osism/tasks/netbox.py #2355

@berendt

Description

@berendt

Background

Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 5 (#2199). osism/tasks/netbox.py (410 LOC) defines the Celery tasks of the netbox worker: setting device custom fields (maintenance, provision_state, power_state) on the primary NetBox and on all matching NETBOX_SECONDARIES instances (Redlock-guarded, semaphore-limited), simple get_* lookup tasks, the manage task wrapping /usr/local/bin/netbox-manager via run_command, and ping. Two private helpers (_update_netbox_device_field, _matches_netbox_filter) carry most of the logic.

Scope

Add tests/unit/tasks/test_netbox.py covering osism/tasks/netbox.py. No existing coverage: tests/unit/tasks/conductor/test_netbox.py covers osism/tasks/conductor/netbox.py (get_device_oob_ip, query-list helpers) and tests/unit/commands/test_netbox.py covers osism/commands/netbox.py — both are different modules, no overlap.

All tasks are bound Celery tasks (bind=True); invoke them directly (e.g. netbox.set_maintenance("node-1", True)) — Celery binds self on direct calls, no broker needed.

The three set_* tasks are structurally identical (same lock/filter/update/skip flow, only the field name and log wording differ). Parametrize over (task, field_name) instead of triplicating every test — same approach as QUERY_LIST_VARIANTS in tests/unit/tasks/conductor/test_netbox.py.

Test targets

_update_netbox_device_field(nb, device_name, field_name, value)netbox.py:19

Patch:

  • osism.tasks.netbox.utils.create_netbox_semaphore → return a MagicMock() (supports the with protocol)

Pass a MagicMock as nb (no patching of utils.nb needed here).

  • Device found → device.custom_fields.update({field_name: value}) and device.save() called, returns True
  • nb.dcim.devices.get returns None → returns False, no save, no error log (the caller logs)
  • requests.exceptions.ConnectTimeout raised → returns False, "Connection timeout" logged
  • requests.exceptions.ReadTimeout raised → returns False, "Request timeout" logged (use ReadTimeout, not ConnectTimeoutConnectTimeout inherits from both ConnectionError and Timeout and is caught by the first handler)
  • requests.exceptions.ConnectionError raised → returns False, "Connection error" logged
  • requests.exceptions.HTTPError (generic RequestException) raised → returns False, "Request error" logged
  • Exception raised by device.save() (not only by get) → same handling, returns False
  • Semaphore is created with nb.base_url and entered/exited around the API call (__enter__/__exit__ asserted)

_matches_netbox_filter(nb, netbox_filter, is_primary=False)netbox.py:66

No patching; pass SimpleNamespace/MagicMock objects with base_url and optional netbox_name/netbox_site attributes.

  • netbox_filter=None and netbox_filter=""True (empty filter matches everything)
  • is_primary=True, filter "primary"True; also case-insensitive ("PRIMARY") and substring ("primary-dc" contains "primary")
  • is_primary=False, filter "primary", URL/name/site without "primary" → False
  • Filter substring of base_url, case-insensitive ("NETBOX.example" vs https://netbox.example.com) → True
  • Object without netbox_name/netbox_site attributes (primary instances) → no AttributeError, falls through to False when URL does not match
  • netbox_name set and matching (case-insensitive substring) → True
  • netbox_site set and matching → True
  • Nothing matches → False

run(action, arguments)netbox.py:104

  • Patch osism.tasks.netbox.utils.check_task_lock_and_exit; direct call returns None, lock check invoked once
  • Lock check raises SystemExit (locked) → propagates

set_maintenance / set_provision_state / set_power_statenetbox.py:117 / netbox.py:190 / netbox.py:263

Patch (parametrized over the three tasks and their field names maintenance / provision_state / power_state):

  • osism.tasks.netbox.utils.check_task_lock_and_exit
  • osism.tasks.netbox.utils.create_redlockMagicMock whose .acquire(timeout=120) returns True/False
  • osism.tasks.netbox._update_netbox_device_field
  • osism.utils.nb — lazy attribute, patch with mocker.patch("osism.utils.nb", new=MagicMock(), create=True) (same convention as tests/unit/tasks/conductor/sonic/conftest.py)

For secondaries, prefer passing the secondary_nb_list=... parameter over patching osism.utils.secondary_nb_list.

  • lock.acquire returns False → returns False, "Could not acquire lock" logged, _update_netbox_device_field never called, lock.release() not called
  • create_redlock called with key=f"lock_osism_tasks_netbox_{device_name}", auto_release_time=300
  • Happy path, netbox_filter=None, secondary_nb_list=[] → update called once with (utils.nb, device_name, field, state), returns True, lock.release() called
  • _update_netbox_device_field returns False → "Could not set ..." error logged, task still returns True
  • netbox_filter not matching the primary (e.g. filter that matches nothing) → primary skipped (update not called for utils.nb), still returns True
  • Two secondaries passed via secondary_nb_list, one matching the filter, one not → update called only for the matching one (plus/minus primary depending on filter)
  • secondary_nb_list=None → falls back to utils.secondary_nb_list (patch the lazy attribute with create=True to a one-element list and assert it is processed)
  • _update_netbox_device_field raises → exception propagates but lock.release() is still called (finally)
  • set_power_state only: state=None → converted to "n/a" (update called with "n/a"); conversion happens before the lock-check
  • One representative test that check_task_lock_and_exit raising SystemExit aborts before create_redlock is called

get_location_id / get_rack_idnetbox.py:340 / netbox.py:352

Patch osism.utils.nb (lazy, create=True). Parametrize over (task, endpoint)dcim.locations vs dcim.racks:

  • Object found → returns .id
  • get returns None → returns None
  • get raises ValueError (pynetbox raises this for ambiguous matches) → returns None

get_devices / get_device_by_name / get_interfaces_by_device / get_addresses_by_device_and_interfacenetbox.py:364 / netbox.py:369 / netbox.py:374 / netbox.py:379

Thin pass-throughs; patch osism.utils.nb and assert delegation:

  • get_devices(role="server", site="x")nb.dcim.devices.filter(role="server", site="x"), return value passed through
  • get_device_by_name("n1")nb.dcim.devices.get(name="n1")
  • get_interfaces_by_device("n1")nb.dcim.interfaces.filter(device="n1")
  • get_addresses_by_device_and_interface("n1", "eth0")nb.dcim.addresses.filter(device="n1", interface="eth0") (test as written — the module uses dcim.addresses)

manage(*arguments, publish=True, locking=False, auto_release_time=3600)netbox.py:384

Patch:

  • osism.tasks.netbox.utils.check_task_lock_and_exit
  • osism.tasks.netbox.run_command (imported into the module namespace via from osism.tasks import ... run_command)
  • osism.tasks.netbox.settings.NETBOX_URL / NETBOX_TOKEN / IGNORE_SSL_ERRORS

Cases:

  • Env dict passed to run_command contains exactly NETBOX_MANAGER_URL, NETBOX_MANAGER_TOKEN, NETBOX_MANAGER_IGNORE_SSL_ERRORS, NETBOX_MANAGER_VERBOSE="true"; all values str()-coerced (e.g. IGNORE_SSL_ERRORS=True"True")
  • Command is /usr/local/bin/netbox-manager; positional *arguments forwarded; publish/locking/auto_release_time forwarded (defaults and explicit values)
  • Returns run_command's return value
  • First positional argument is self.request.id — on a direct call this is None; that is fine since run_command is mocked (alternatively use manage.apply(args=[...]) for a real task id)
  • Lock check raising SystemExitrun_command never called

ping()netbox.py:407

  • Patch osism.utils.nb; returns utils.nb.status() result unchanged

setup_periodic_tasks(sender, **kwargs)netbox.py:15

  • No-op signal handler; call once with sender=None for line coverage

Mocking hints

  • utils.nb and utils.secondary_nb_list are lazy module attributes resolved via osism.utils.__getattr__ — always patch with create=True and target osism.utils.nb (never trigger the real __getattr__, it would open a NetBox connection). See the mock_nb fixtures in tests/unit/tasks/conductor/sonic/conftest.py and tests/unit/tasks/conductor/test_netbox.py:59.
  • utils.check_task_lock_and_exit calls exit(1) when locked; in tests, set side_effect=SystemExit(1) on the patched mock for the "locked" cases and assert with pytest.raises(SystemExit).
  • The Redlock mock needs only acquire(timeout=120) -> bool and release(); assert the exact timeout=120 kwarg.
  • Build secondary instances as MagicMock(base_url="https://nb2.example.com") and set netbox_name / netbox_site attributes explicitly where a test needs them; use spec/SimpleNamespace for the "attribute absent" cases so getattr(..., None) actually returns None.
  • For the set_* tasks, patching osism.tasks.netbox._update_netbox_device_field keeps the lock/filter tests independent of the helper, which is tested separately.
  • Importing osism.tasks.netbox instantiates Celery("netbox") with config_from_object(Config) — config only, no broker connection; safe in unit tests (same pattern as the other osism.tasks modules already imported by existing tests).

Definition of Done

  • tests/unit/tasks/test_netbox.py created
  • All listed cases covered
  • pytest --cov=osism.tasks.netbox shows ≥ 90 %
  • pipenv run pytest tests/unit/tasks/test_netbox.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

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions