Skip to content

Unit tests for osism/tasks/openstack.py #2356

Description

@berendt

Background

Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 5 (#2199). osism/tasks/openstack.py (931 LOC) is the OpenStack Celery worker module: thin task wrappers around the OpenStack SDK (image/network/baremetal CRUD), baremetal info getters used by CLI and API (incl. NetBox enrichment and secret masking), cloud credential/environment helpers (secrets.yml password loading, clouds.yaml injection into /tmp, secure.yml fallback), and manager tasks (image_manager, flavor_manager, project_manager*).

Scope

Add tests/unit/tasks/test_openstack.py. There is no existing test file for this module — existing command tests (tests/unit/commands/test_server.py, test_volume.py, test_compute.py, test_baremetal.py, test_manage_wiring.py) only patch osism.tasks.openstack.get_cloud_helpers / image_manager as collaborators, so nothing below duplicates existing coverage.

The module is large (~32 functions). Prioritize the pure-logic / high-value functions listed first (credential handling, environment setup, baremetal getters, secret masking); the thin SDK task wrappers are covered by one parametrized test block. Like the Tier 3 issues, this issue may be split further during implementation, e.g. (a) cloud credential/environment helpers, (b) baremetal/NetBox getters incl. masking, (c) Celery task wrappers + manager tasks.

Test targets

get_cloud_password(cloud)openstack.py:417

Patch:

  • osism.tasks.openstack.os.path.exists
  • osism.tasks.openstack.load_yaml_file (imported at module top from osism.tasks.conductor.utils)

Cases:

  • cloud=None / "" → returns None, warning "No cloud parameter provided"
  • Secrets file missing (os.path.existsFalse) → None, warning
  • load_yaml_file returns NoneNone, warning "Empty or invalid secrets file"
  • load_yaml_file returns a non-dict (list) → None
  • Hyphen normalization: cloud="admin-system" → looks up key os_password_admin_system
  • Key-format guard: cloud="admin system"password_key is not an identifier → error logged, None
  • Key not present in dict → None (debug log only, no warning)
  • Value " hunter2 " → returns "hunter2" (stripped)
  • Value 42 (int) → coerced via str(...)"42"
  • Value "" / whitespace-only → None, warning "empty after conversion"
  • load_yaml_file raises → None, error logged

setup_cloud_environment(cloud)openstack.py:487

Patch:

  • osism.tasks.openstack.get_cloud_password
  • osism.tasks.openstack.os.path.exists (side-effect map over the hardcoded paths), os.getcwd, os.chdir
  • osism.tasks.openstack.shutil.copy2
  • builtins.open (mock_open) + osism.tasks.openstack.yaml.safe_load / yaml.dump for the password-injection branch

Cases:

  • cloud falsy → (None, [], original_cwd, False), warning; get_cloud_password not called
  • Password branch (password found):
    • /etc/openstack/clouds.yaml exists, cloud present in config → password injected into clouds["<cloud>"]["auth"]["password"], combined config written to /tmp/clouds.yaml, empty /tmp/secure.yml written, os.chdir("/tmp"), returns (password, ["/tmp/clouds.yaml", "/tmp/secure.yml"], original_cwd, True)
    • Only /etc/openstack/clouds.yml exists → destination is /tmp/clouds.yml
    • Cloud profile present but without auth key → auth dict created before injection
    • Cloud profile missing from clouds_config(None, [], original_cwd, False), warning
    • Neither clouds.yaml nor clouds.yml → returns (password, [], original_cwd, True) (password still usable for direct SDK), no chdir
    • open/yaml raises → (None, ..., False), error logged
  • Fallback branch (no password):
    • /etc/openstack/secure.yml exists → clouds.yaml + secure.yml copied to /tmp, chdir to /tmp, returns (None, [copied files], original_cwd, True)
    • Only /etc/openstack/secure.yaml exists → destination /tmp/secure.yaml
    • Neither secure file → (None, [], original_cwd, False), error message names os_password_<cloud_normalized>
    • shutil.copy2 raises → (None, ..., False), error logged

cleanup_cloud_environment(temp_files_to_cleanup, original_cwd)openstack.py:609

Patch osism.tasks.openstack.os.chdir, os.path.exists, os.remove.

  • Restores cwd and removes each existing temp file
  • os.chdir raises → warning, file removal still happens
  • File no longer exists → os.remove not called for it
  • os.remove raises for one file → warning, remaining files still processed

get_openstack_connection(cloud, password=None)openstack.py:633

This is the module's own two-argument helper (raises SystemExit) — not to be confused with osism.utils.get_openstack_connection() used by the Celery tasks. openstack / keystoneauth1 are imported inside the function: patch openstack.connect at source; openstacksdk and keystoneauth1 are direct dependencies, so raise the real exception classes. Also patch osism.tasks.openstack.os.path.exists for the secure_yml_exists check.

  • password given, connect OK → openstack.connect(cloud=cloud, auth={"password": password}), conn.current_project touched, conn returned
  • password given, Unauthorized, secure.yml exists → second openstack.connect(cloud=cloud) (no auth) succeeds → conn returned (use side_effect=[Unauthorized(), mock_conn])
  • password given, Unauthorized, no secure.yml → SystemExit raised (pytest.raises(SystemExit)), error names os_password_<cloud_normalized>
  • password given, MissingRequiredOptionsSystemExit
  • password given, SDKExceptionSystemExit
  • No password, connect OK → conn returned
  • No password, MissingRequiredOptions whose message contains "password" → SystemExit, specific "No password configured" error
  • No password, MissingRequiredOptions without "password" → SystemExit, generic "Missing configuration" error
  • No password, UnauthorizedSystemExit
  • No password, SDKExceptionSystemExit
  • connect succeeds but current_project access raises (PropertyMock side effect) → exercises test_connection failure path

Hint: MissingRequiredOptions(options) builds its message from option objects — construct with e.g. types.SimpleNamespace(name="password").

run_openstack_command_with_cloud(...)openstack.py:723

Patch osism.tasks.openstack.setup_cloud_environment, cleanup_cloud_environment, run_command.

  • run_command called with (request_id, command, {}, *arguments) plus publish / locking / auto_release_time / ignore_env=True; return value propagated
  • cleanup_cloud_environment called with the temp files and cwd from setup even when run_command raises (finally)
  • Setup returning success=False does not short-circuit — run_command still runs (assert current behavior)

get_baremetal_nodes()openstack.py:78

Patch osism.tasks.openstack.utils.get_openstack_connection (the no-arg helper on osism.utils).

  • Two fake nodes (types.SimpleNamespace — code uses getattr) → list of dicts with all expected keys; conn.baremetal.nodes called with details=True
  • uuid missing/None → falls back to id
  • traits=None[]
  • driver_info=Noneredfish_address is None (via or {}); driver_info={"redfish_address": "https://..."} → extracted
  • Empty node generator → []
  • device_role / primary_ip4 / primary_ip6 are always None placeholders

get_baremetal_node_netbox_info(node_name)openstack.py:131

Patch osism.tasks.openstack.utils.nb (lazy attribute on osism.utils, see hints) and osism.tasks.openstack.settings.NETBOX_URL.

  • utils.nb is None → default dict (all None)
  • node_name empty → default dict
  • Device found via nb.dcim.devices.get(name=...): role.name extracted; primary_ip4="10.0.0.1/24""10.0.0.1" (split on /); same for primary_ip6; netbox_url = NETBOX_URL.rstrip('/') + /dcim/devices/<id>/
  • get returns None, filter(cf_inventory_hostname=...) returns devices → first match used
  • filter returns empty → defaults returned
  • device.role is None or has no name attribute → device_role stays None
  • NETBOX_URL unset → netbox_url stays None
  • NetBox lookup raises → defaults returned, debug log

get_baremetal_nodes_netbox_info(node_names)openstack.py:170

  • utils.nb falsy → {}; node_names empty → {}
  • Two names → dict keyed by name; patch osism.tasks.openstack.get_baremetal_node_netbox_info and assert one call per name

get_baremetal_node_ports(node_uuid)openstack.py:190

  • conn.baremetal.ports called with details=True, node_uuid=...; returned objects mapped to dicts, uuid falls back to id; empty generator → []

get_baremetal_node_parameters(node_uuid)openstack.py:214 (high value: secret masking)

Patch osism.tasks.openstack.utils.get_openstack_connection, osism.tasks.openstack.utils.nb; the conductor helpers are imported inside the function, so patch at source: osism.tasks.conductor.utils.get_vault, osism.tasks.conductor.utils.deep_decrypt (the real mask_secrets can be exercised, or patched likewise).

  • extra empty / None{"kernel_append_params": None, "netplan_parameters": None, "frr_parameters": None}
  • extra["instance_info"] valid JSON with kernel_append_params → returned
  • instance_info invalid JSON or non-string (TypeError) → ignored, result None
  • netplan_parameters / frr_parameters valid JSON → parsed dicts; invalid JSON → None
  • utils.nb is None or node has no name → no masking, raw kernel_append_params returned
  • Device found, custom_fields["secrets"] is None → treated as {}
  • Secrets with password/secret in key → string values (stripped) replaced by *** in kernel_append_params
  • ironic_osism_aa key → param-name collected before deep_decrypt; osism-aa=value masked to osism-aa=*** by regex even when deep_decrypt drops the key (failed vault decryption)
  • Non-string secret values are not collected
  • Collected secret_values passed into mask_secrets(...) for frr_parameters and netplan_parameters with mask="***"
  • Device lookup via cf_inventory_hostname fallback
  • NetBox/vault path raises → debug log, parameters returned unmasked

Thin Celery task wrappers (parametrized block)

image_get (openstack.py:25), network_get (:32), baremetal_node_create (:39), baremetal_node_delete (:49), baremetal_node_update (:56), baremetal_node_show (:65), baremetal_node_list (:72), baremetal_node_validate (:337), baremetal_node_wait_for_nodes_provision_state (:347), baremetal_node_set_boot_device (:357), baremetal_node_set_provision_state (:363), baremetal_node_set_power_state (:370), baremetal_port_list (:381), baremetal_port_create (:390), baremetal_port_delete (:399), baremetal_node_set_target_raid_config (:406).

Patch osism.tasks.openstack.utils.get_openstack_connection; call each task object directly (synchronous, no broker). Mostly one happy-path assertion each (correct SDK method + args, return value propagated) via pytest.mark.parametrize; plus these specific branches:

  • baremetal_node_create: attributes=Nonecreate_node(name=node_name); attributes given → name injected into them
  • baremetal_node_wait_for_nodes_provision_state: non-empty result list → first element; empty list → None
  • baremetal_node_set_power_state: wait=Truewait_for_node_power_state(node, state, timeout=timeout); wait=Falseget_node(node)
  • baremetal_node_list / baremetal_port_list: generator materialized via list()
  • baremetal_node_set_target_raid_config: get_node then conn.baremetal.put("/nodes/<uuid>/states/raid", microversion="1.12", json=...), returns (response.ok, response.content)
  • get_cloud_helpers() (openstack.py:483): returns the 3-tuple (setup_cloud_environment, get_openstack_connection, cleanup_cloud_environment)

image_manager(...)openstack.py:765

Patch osism.tasks.openstack.utils.check_task_lock_and_exit, setup_cloud_environment, cleanup_cloud_environment, run_command, and (for the no-configs case) run_openstack_command_with_cloud.

  • check_task_lock_and_exit called first in both branches
  • No configs → delegates to run_openstack_command_with_cloud with the manager binary path and pass-through kwargs
  • configs=["yaml-a", "yaml-b"] → each config written to a temp file inside a TemporaryDirectory; --images=foo args filtered out; --images foo pair removed; --images <temp_dir> appended; run_command called with ignore_env=True
  • cleanup_cloud_environment called even when run_command raises (finally)
  • Edge (document current behavior): a trailing --images without value raises IndexError (only ValueError is caught)

flavor_manager (openstack.py:831), project_manager (:855), project_manager_sync (:895)

  • flavor_manager: patch run_openstack_command_with_cloud → called with /usr/local/bin/openstack-flavor-manager, cloud and kwargs; lock check invoked
  • project_manager / project_manager_sync (parametrize over create.py / manage.py): patch setup_cloud_environment, cleanup_cloud_environment, run_command, os.chdir → script path prepended to arguments, os.chdir("/openstack-project-manager") called, cleanup in finally

Mocking hints

  • Two different get_openstack_connections: the Celery tasks/getters use the no-arg osism.utils.get_openstack_connection() — patch osism.tasks.openstack.utils.get_openstack_connection. The module-level get_openstack_connection(cloud, password) at openstack.py:633 is a separate function under test. Don't mix them up.
  • All tasks are bound (bind=True); invoke the task object directly (e.g. openstack.image_get("cirros")) — direct calls execute the body synchronously without a broker; self.request.id is None, which is fine for the run_command mocks.
  • utils.nb is a lazy module attribute resolved via __getattr__ on osism.utils — patch with mocker.patch("osism.tasks.openstack.utils.nb", mock_nb) (same pattern as in Unit tests for osism/utils/rabbitmq.py #2232 for utils.redis).
  • load_yaml_file is imported at module top → patch osism.tasks.openstack.load_yaml_file. deep_decrypt / get_vault / mask_secrets are imported inside get_baremetal_node_parameters → patch at osism.tasks.conductor.utils.*. The root tests/conftest.py already stubs ansible.*, so importing conductor utils is safe in the unit-test env.
  • run_command is imported at module top from osism.tasks → patch osism.tasks.openstack.run_command.
  • Use types.SimpleNamespace for fake SDK Resource objects — the getters use getattr(node, ..., default) throughout, so plain namespaces model missing attributes correctly (unlike MagicMock, which auto-creates attributes).
  • setup_cloud_environment / cleanup_cloud_environment use hardcoded paths (/etc/openstack/..., /tmp/...) and os.chdir — always patch os.path.exists (side-effect map keyed by path), os.chdir, os.getcwd, shutil.copy2, and builtins.open so tests never touch the real filesystem or working directory.
  • openstacksdk and keystoneauth1 are pinned runtime dependencies — raise the real keystoneauth1.exceptions.http.Unauthorized, keystoneauth1.exceptions.auth_plugins.MissingRequiredOptions, and openstack.exceptions.SDKException classes via side_effect instead of inventing fakes.

Definition of Done

  • tests/unit/tasks/test_openstack.py created
  • All listed cases covered (split into multiple sub-issues/PRs if too large — the priority order above applies)
  • pytest --cov=osism.tasks.openstack shows ≥ 90 %
  • pipenv run pytest tests/unit/tasks/test_openstack.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