You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 patchosism.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)
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])
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
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).
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 beforedeep_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
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=None → create_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_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: 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 insideget_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
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.ymlpassword loading,clouds.yamlinjection into/tmp,secure.ymlfallback), 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 patchosism.tasks.openstack.get_cloud_helpers/image_manageras 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:417Patch:
osism.tasks.openstack.os.path.existsosism.tasks.openstack.load_yaml_file(imported at module top fromosism.tasks.conductor.utils)Cases:
cloud=None/""→ returnsNone, warning "No cloud parameter provided"os.path.exists→False) →None, warningload_yaml_filereturnsNone→None, warning "Empty or invalid secrets file"load_yaml_filereturns a non-dict (list) →Nonecloud="admin-system"→ looks up keyos_password_admin_systemcloud="admin system"→password_keyis not an identifier → error logged,NoneNone(debug log only, no warning)" hunter2 "→ returns"hunter2"(stripped)42(int) → coerced viastr(...)→"42"""/ whitespace-only →None, warning "empty after conversion"load_yaml_fileraises →None, error loggedsetup_cloud_environment(cloud)—openstack.py:487Patch:
osism.tasks.openstack.get_cloud_passwordosism.tasks.openstack.os.path.exists(side-effect map over the hardcoded paths),os.getcwd,os.chdirosism.tasks.openstack.shutil.copy2builtins.open(mock_open) +osism.tasks.openstack.yaml.safe_load/yaml.dumpfor the password-injection branchCases:
cloudfalsy →(None, [], original_cwd, False), warning;get_cloud_passwordnot called/etc/openstack/clouds.yamlexists, cloud present in config → password injected intoclouds["<cloud>"]["auth"]["password"], combined config written to/tmp/clouds.yaml, empty/tmp/secure.ymlwritten,os.chdir("/tmp"), returns(password, ["/tmp/clouds.yaml", "/tmp/secure.yml"], original_cwd, True)/etc/openstack/clouds.ymlexists → destination is/tmp/clouds.ymlauthkey →authdict created before injectionclouds_config→(None, [], original_cwd, False), warningclouds.yamlnorclouds.yml→ returns(password, [], original_cwd, True)(password still usable for direct SDK), no chdiropen/yamlraises →(None, ..., False), error logged/etc/openstack/secure.ymlexists →clouds.yaml+secure.ymlcopied to/tmp, chdir to/tmp, returns(None, [copied files], original_cwd, True)/etc/openstack/secure.yamlexists → destination/tmp/secure.yaml(None, [], original_cwd, False), error message namesos_password_<cloud_normalized>shutil.copy2raises →(None, ..., False), error loggedcleanup_cloud_environment(temp_files_to_cleanup, original_cwd)—openstack.py:609Patch
osism.tasks.openstack.os.chdir,os.path.exists,os.remove.os.chdirraises → warning, file removal still happensos.removenot called for itos.removeraises for one file → warning, remaining files still processedget_openstack_connection(cloud, password=None)—openstack.py:633This is the module's own two-argument helper (raises
SystemExit) — not to be confused withosism.utils.get_openstack_connection()used by the Celery tasks.openstack/keystoneauth1are imported inside the function: patchopenstack.connectat source;openstacksdkandkeystoneauth1are direct dependencies, so raise the real exception classes. Also patchosism.tasks.openstack.os.path.existsfor thesecure_yml_existscheck.passwordgiven, connect OK →openstack.connect(cloud=cloud, auth={"password": password}),conn.current_projecttouched, conn returnedpasswordgiven,Unauthorized, secure.yml exists → secondopenstack.connect(cloud=cloud)(no auth) succeeds → conn returned (useside_effect=[Unauthorized(), mock_conn])passwordgiven,Unauthorized, no secure.yml →SystemExitraised (pytest.raises(SystemExit)), error namesos_password_<cloud_normalized>passwordgiven,MissingRequiredOptions→SystemExitpasswordgiven,SDKException→SystemExitMissingRequiredOptionswhose message contains "password" →SystemExit, specific "No password configured" errorMissingRequiredOptionswithout "password" →SystemExit, generic "Missing configuration" errorUnauthorized→SystemExitSDKException→SystemExitconnectsucceeds butcurrent_projectaccess raises (PropertyMock side effect) → exercisestest_connectionfailure pathHint:
MissingRequiredOptions(options)builds its message from option objects — construct with e.g.types.SimpleNamespace(name="password").run_openstack_command_with_cloud(...)—openstack.py:723Patch
osism.tasks.openstack.setup_cloud_environment,cleanup_cloud_environment,run_command.run_commandcalled with(request_id, command, {}, *arguments)pluspublish/locking/auto_release_time/ignore_env=True; return value propagatedcleanup_cloud_environmentcalled with the temp files and cwd from setup even whenrun_commandraises (finally)success=Falsedoes not short-circuit —run_commandstill runs (assert current behavior)get_baremetal_nodes()—openstack.py:78Patch
osism.tasks.openstack.utils.get_openstack_connection(the no-arg helper onosism.utils).types.SimpleNamespace— code usesgetattr) → list of dicts with all expected keys;conn.baremetal.nodescalled withdetails=Trueuuidmissing/None → falls back toidtraits=None→[]driver_info=None→redfish_addressisNone(viaor {});driver_info={"redfish_address": "https://..."}→ extracted[]device_role/primary_ip4/primary_ip6are alwaysNoneplaceholdersget_baremetal_node_netbox_info(node_name)—openstack.py:131Patch
osism.tasks.openstack.utils.nb(lazy attribute onosism.utils, see hints) andosism.tasks.openstack.settings.NETBOX_URL.utils.nbisNone→ default dict (allNone)node_nameempty → default dictnb.dcim.devices.get(name=...):role.nameextracted;primary_ip4="10.0.0.1/24"→"10.0.0.1"(split on/); same forprimary_ip6;netbox_url=NETBOX_URL.rstrip('/') + /dcim/devices/<id>/getreturnsNone,filter(cf_inventory_hostname=...)returns devices → first match usedfilterreturns empty → defaults returneddevice.roleisNoneor has nonameattribute →device_rolestaysNoneNETBOX_URLunset →netbox_urlstaysNoneget_baremetal_nodes_netbox_info(node_names)—openstack.py:170utils.nbfalsy →{};node_namesempty →{}osism.tasks.openstack.get_baremetal_node_netbox_infoand assert one call per nameget_baremetal_node_ports(node_uuid)—openstack.py:190conn.baremetal.portscalled withdetails=True, node_uuid=...; returned objects mapped to dicts,uuidfalls back toid; 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 realmask_secretscan be exercised, or patched likewise).extraempty /None→{"kernel_append_params": None, "netplan_parameters": None, "frr_parameters": None}extra["instance_info"]valid JSON withkernel_append_params→ returnedinstance_infoinvalid JSON or non-string (TypeError) → ignored, resultNonenetplan_parameters/frr_parametersvalid JSON → parsed dicts; invalid JSON →Noneutils.nbisNoneor node has no name → no masking, rawkernel_append_paramsreturnedcustom_fields["secrets"]isNone→ treated as{}password/secretin key → string values (stripped) replaced by***inkernel_append_paramsironic_osism_aakey → param-name collected beforedeep_decrypt;osism-aa=valuemasked toosism-aa=***by regex even whendeep_decryptdrops the key (failed vault decryption)secret_valuespassed intomask_secrets(...)forfrr_parametersandnetplan_parameterswithmask="***"cf_inventory_hostnamefallbackThin 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) viapytest.mark.parametrize; plus these specific branches:baremetal_node_create:attributes=None→create_node(name=node_name); attributes given →nameinjected into thembaremetal_node_wait_for_nodes_provision_state: non-empty result list → first element; empty list →Nonebaremetal_node_set_power_state:wait=True→wait_for_node_power_state(node, state, timeout=timeout);wait=False→get_node(node)baremetal_node_list/baremetal_port_list: generator materialized vialist()baremetal_node_set_target_raid_config:get_nodethenconn.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:765Patch
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_exitcalled first in both branchesconfigs→ delegates torun_openstack_command_with_cloudwith the manager binary path and pass-through kwargsconfigs=["yaml-a", "yaml-b"]→ each config written to a temp file inside aTemporaryDirectory;--images=fooargs filtered out;--images foopair removed;--images <temp_dir>appended;run_commandcalled withignore_env=Truecleanup_cloud_environmentcalled even whenrun_commandraises (finally)--imageswithout value raisesIndexError(onlyValueErroris caught)flavor_manager(openstack.py:831),project_manager(:855),project_manager_sync(:895)flavor_manager: patchrun_openstack_command_with_cloud→ called with/usr/local/bin/openstack-flavor-manager, cloud and kwargs; lock check invokedproject_manager/project_manager_sync(parametrize overcreate.py/manage.py): patchsetup_cloud_environment,cleanup_cloud_environment,run_command,os.chdir→ script path prepended to arguments,os.chdir("/openstack-project-manager")called, cleanup infinallyMocking hints
get_openstack_connections: the Celery tasks/getters use the no-argosism.utils.get_openstack_connection()— patchosism.tasks.openstack.utils.get_openstack_connection. The module-levelget_openstack_connection(cloud, password)atopenstack.py:633is a separate function under test. Don't mix them up.bind=True); invoke the task object directly (e.g.openstack.image_get("cirros")) — direct calls execute the body synchronously without a broker;self.request.idisNone, which is fine for therun_commandmocks.utils.nbis a lazy module attribute resolved via__getattr__onosism.utils— patch withmocker.patch("osism.tasks.openstack.utils.nb", mock_nb)(same pattern as in Unit tests for osism/utils/rabbitmq.py #2232 forutils.redis).load_yaml_fileis imported at module top → patchosism.tasks.openstack.load_yaml_file.deep_decrypt/get_vault/mask_secretsare imported insideget_baremetal_node_parameters→ patch atosism.tasks.conductor.utils.*. The roottests/conftest.pyalready stubsansible.*, so importing conductor utils is safe in the unit-test env.run_commandis imported at module top fromosism.tasks→ patchosism.tasks.openstack.run_command.types.SimpleNamespacefor fake SDK Resource objects — the getters usegetattr(node, ..., default)throughout, so plain namespaces model missing attributes correctly (unlikeMagicMock, which auto-creates attributes).setup_cloud_environment/cleanup_cloud_environmentuse hardcoded paths (/etc/openstack/...,/tmp/...) andos.chdir— always patchos.path.exists(side-effect map keyed by path),os.chdir,os.getcwd,shutil.copy2, andbuiltins.openso tests never touch the real filesystem or working directory.openstacksdkandkeystoneauth1are pinned runtime dependencies — raise the realkeystoneauth1.exceptions.http.Unauthorized,keystoneauth1.exceptions.auth_plugins.MissingRequiredOptions, andopenstack.exceptions.SDKExceptionclasses viaside_effectinstead of inventing fakes.Definition of Done
tests/unit/tasks/test_openstack.pycreatedpytest --cov=osism.tasks.openstackshows ≥ 90 %pipenv run pytest tests/unit/tasks/test_openstack.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies