Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). First step in Tier 2 (#2199): the small, mostly-pure helpers in osism/tasks/conductor/utils.py that are used everywhere else in the conductor package. Covering them first means later tier issues can rely on them being well-tested.
The five targets are largely pure dict/string logic. The only external dependency is ansible.parsing.vault.VaultLib (used by deep_decrypt and load_yaml_file), which is straightforward to mock.
Scope
Add tests/unit/tasks/conductor/test_utils.py covering five functions in osism/tasks/conductor/utils.py. Out of scope (separate issues): get_redfish_connection, _get_conductor_redfish_*, check_task_lock_and_exit, mask_secrets / _mask_secrets_inplace, get_vault (only indirectly via load_yaml_file).
Test targets
deep_compare(a, b, updates) — utils.py:16
Recursively walks a and writes diffs vs. b into updates. Pure function, no mocking.
- Identical dicts →
updates stays empty
- Key in
a, missing in b → recorded in updates
- Same key, different scalar values → recorded
- Nested dict where inner value differs → nested entry in
updates
- Nested dict where inner subtree is fully equal → that branch is removed from
updates (the cleanup branch)
- Empty
a → updates untouched
deep_merge(a, b) — utils.py:32
Recursively merges b into a in place. Pure function, no mocking.
- Disjoint keys → both kept
- Overlapping scalar key →
b's value wins
- Both values are dicts → merged recursively
- Type mismatch (dict vs scalar) →
b's value overwrites
- Sentinel
\"DELETE\" value in b → corresponding key removed from a
\"DELETE\" for a key that does not exist in a → no error
- Mutation: assert
a is mutated in place and b is unchanged
deep_decrypt(a, vault) — utils.py:47
Recursively decrypts vault-encrypted strings inside dicts/lists. Mock the vault parameter (a VaultLib instance) — patch its is_encrypted and decrypt methods.
a is None → returns immediately, no calls on vault
- Dict with one encrypted string value → replaced with decoded/stripped plaintext
- Dict with one plaintext value → untouched (
is_encrypted returns False)
- Nested dict / nested list → recursion descends
- List containing encrypted strings → values replaced in place
- Decrypt raises
Exception for a dict value → key is removed from the dict
- Decrypt raises
Exception for a list element → element is left untouched (pass branch)
- Verify
decrypt returns bytes and the result is .decode().strip()
_is_secret_key(key) — utils.py:280
- Non-string input (int, None, bytes) →
False
\"password\", \"PASSWORD\", \"db_password\" → True
\"secret\", \"client_secret\", \"SECRET_KEY\" → True
\"ironic_osism_foo\", \"IRONIC_OSISM_BAR\" → True
\"username\", \"host\", \"ironic_other\" (does not match the prefix exactly) → False
- Empty string →
False
load_yaml_file(path) — utils.py:95
Reads a file, optionally vault-decrypts it, and returns the parsed YAML. Uses tmp_path for filesystem cases; patch osism.tasks.conductor.utils.get_vault for the encrypted-file case.
- Path does not exist → returns
None (no exception)
- Plain YAML file → returns parsed dict
- Empty YAML file → returns
None (yaml.safe_load on empty input)
- Malformed YAML → returns
None (yaml.YAMLError swallowed, debug-logged)
OSError on read (e.g. patch builtins.open to raise) → returns None
- Encrypted file: patch
VaultLib.is_encrypted to True and patch get_vault().decrypt to return valid YAML bytes → returns parsed dict
- Encrypted file where
decrypt raises AnsibleError → returns None
Mocking hints
- For
deep_decrypt: build a MagicMock(spec=VaultLib) and configure is_encrypted.side_effect / decrypt.side_effect per case — no need to touch the real ansible-vault stack.
- For
load_yaml_file (encrypted branch): patch osism.tasks.conductor.utils.get_vault so the test never reaches osism.utils.get_ansible_vault_password() (which would hit Redis).
- For the
is_encrypted probe inside load_yaml_file (line ~105): it constructs a fresh VaultLib() just to call .is_encrypted(). Either patch osism.tasks.conductor.utils.VaultLib or pass content that the real method correctly classifies (a real plaintext file works without any patch).
- File I/O: prefer
tmp_path over patching open, except for the OSError case.
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). First step in Tier 2 (#2199): the small, mostly-pure helpers in
osism/tasks/conductor/utils.pythat are used everywhere else in the conductor package. Covering them first means later tier issues can rely on them being well-tested.The five targets are largely pure dict/string logic. The only external dependency is
ansible.parsing.vault.VaultLib(used bydeep_decryptandload_yaml_file), which is straightforward to mock.Scope
Add
tests/unit/tasks/conductor/test_utils.pycovering five functions inosism/tasks/conductor/utils.py. Out of scope (separate issues):get_redfish_connection,_get_conductor_redfish_*,check_task_lock_and_exit,mask_secrets/_mask_secrets_inplace,get_vault(only indirectly viaload_yaml_file).Test targets
deep_compare(a, b, updates)—utils.py:16Recursively walks
aand writes diffs vs.bintoupdates. Pure function, no mocking.updatesstays emptya, missing inb→ recorded inupdatesupdatesupdates(the cleanup branch)a→updatesuntoucheddeep_merge(a, b)—utils.py:32Recursively merges
bintoain place. Pure function, no mocking.b's value winsb's value overwrites\"DELETE\"value inb→ corresponding key removed froma\"DELETE\"for a key that does not exist ina→ no errorais mutated in place andbis unchangeddeep_decrypt(a, vault)—utils.py:47Recursively decrypts vault-encrypted strings inside dicts/lists. Mock the
vaultparameter (aVaultLibinstance) — patch itsis_encryptedanddecryptmethods.a is None→ returns immediately, no calls onvaultis_encryptedreturnsFalse)Exceptionfor a dict value → key is removed from the dictExceptionfor a list element → element is left untouched (passbranch)decryptreturnsbytesand the result is.decode().strip()_is_secret_key(key)—utils.py:280False\"password\",\"PASSWORD\",\"db_password\"→True\"secret\",\"client_secret\",\"SECRET_KEY\"→True\"ironic_osism_foo\",\"IRONIC_OSISM_BAR\"→True\"username\",\"host\",\"ironic_other\"(does not match the prefix exactly) →FalseFalseload_yaml_file(path)—utils.py:95Reads a file, optionally vault-decrypts it, and returns the parsed YAML. Uses
tmp_pathfor filesystem cases; patchosism.tasks.conductor.utils.get_vaultfor the encrypted-file case.None(no exception)None(yaml.safe_load on empty input)None(yaml.YAMLErrorswallowed, debug-logged)OSErroron read (e.g. patchbuiltins.opento raise) → returnsNoneVaultLib.is_encryptedtoTrueand patchget_vault().decryptto return valid YAML bytes → returns parsed dictdecryptraisesAnsibleError→ returnsNoneMocking hints
deep_decrypt: build aMagicMock(spec=VaultLib)and configureis_encrypted.side_effect/decrypt.side_effectper case — no need to touch the real ansible-vault stack.load_yaml_file(encrypted branch): patchosism.tasks.conductor.utils.get_vaultso the test never reachesosism.utils.get_ansible_vault_password()(which would hit Redis).is_encryptedprobe insideload_yaml_file(line ~105): it constructs a freshVaultLib()just to call.is_encrypted(). Either patchosism.tasks.conductor.utils.VaultLibor pass content that the real method correctly classifies (a real plaintext file works without any patch).tmp_pathover patchingopen, except for theOSErrorcase.Definition of Done
tests/unit/tasks/__init__.py,tests/unit/tasks/conductor/__init__.pycreatedtests/unit/tasks/conductor/test_utils.pycovers all listed casespytest --cov=osism.tasks.conductor.utilsshows 100 % for the five target functions (other functions in the file may stay uncovered for now)pipenv run pytest tests/unit/tasks/conductor/test_utils.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies