Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 8 (#2199). This issue covers the hardware-related CLI command modules: osism/commands/baremetal.py (1729 LOC, 14 cliff commands around Ironic node lifecycle), osism/commands/redfish.py (224 LOC, Redfish resource listing via a Celery task), osism/commands/server.py (454 LOC, Nova server migrate/list/clean), and osism/commands/stress.py (242 LOC, wrapper around the openstack-simple-stress tool). Together ~2650 LOC — the largest CLI group in the meta issue.
Scope
Extend tests/unit/commands/test_baremetal.py and tests/unit/commands/test_server.py; create tests/unit/commands/test_redfish.py and tests/unit/commands/test_stress.py.
Already covered — do not duplicate:
tests/unit/commands/test_baremetal.py: node-not-found → exit 1 for all ten node commands (Deploy, Undeploy, BurnIn, Clean, Provide, MaintenanceSet/Unset, PowerOn/Off, Delete); BaremetalDump failure paths (--ironic node not found, NetBox unavailable, device not found); BaremetalPing failure paths (NetBox unavailable, device not found, generic exception); missing-name argument validation for eight commands; burn-in with all stressors disabled; --all without --yes-i-really-really-mean-it for Deploy(--rebuild)/Undeploy/Clean/Delete.
tests/unit/commands/test_server.py: ServerList lookup failures (user-domain, user, domain, project-domain, project not found → exit 1); ServerMigrate refuses non-ACTIVE/PAUSED server.
- No tests exist for
redfish.py or stress.py.
The remaining gaps are almost exclusively happy paths and state-machine branches. Given the size of baremetal.py, prioritize the pure-logic targets (_apply_metalbox_vars, BaremetalPing._ping_host, clean-step construction, provision-state decision matrices) first; the simple wrapper commands (Provide/Maintenance/Power) are one parametrized block. This issue may be split further during implementation (e.g. baremetal.py into its own sub-issue, analogous to the Tier 3 splits).
Test targets
osism/commands/baremetal.py — gaps only
_apply_metalbox_vars() — baremetal.py:19
Patch osism.commands.baremetal._get_metalbox_primary_ip4 (imported at module top).
- IP returned (
"192.168.30.1") → play_vars["hosts_additional_entries"] == {"metalbox.osism.xyz": "192.168.30.1"} and play_vars["docker_insecure_registries"] == ["metalbox:5001"]
- Returns
None/falsy → play_vars left untouched
BaremetalList.take_action() — baremetal.py:56
Patch osism.tasks.openstack.get_cloud_helpers (pattern from existing _run_not_found); osism.utils.nb via patch.dict("osism.utils.__dict__", ...); osism.tasks.openstack.get_baremetal_node_netbox_info for --netbox.
setup_cloud_environment returns success=False → returns 1, get_openstack_connection never called (this guard is identical in every command of the module — one parametrized test over the command classes is enough)
- Two nodes returned out of order → table rows sorted by name (capsys);
power_state=None rendered as "n/a"
--provision-state active --maintenance → conn.baremetal.nodes called with provision_state="active", maintenance=True; no flags → called with no query kwargs
--netbox with utils.nb set → get_baremetal_node_netbox_info called per row, "Device Role" header inserted at index 1, device_role=None → "N/A"
--netbox with utils.nb = None → no extra column
cleanup_cloud_environment called even when conn.baremetal.nodes raises (finally block)
BaremetalDeploy.take_action() — baremetal.py:157
Patch: osism.tasks.openstack.get_cloud_helpers, osism.tasks.conductor.utils.get_vault / osism.tasks.conductor.utils.deep_decrypt (imported inside take_action — patch at source), openstack.baremetal.configdrive.pack (imported inside the loop — patch at source to avoid building a real config drive), osism.utils.nb, osism.commands.baremetal._get_metalbox_primary_ip4.
- Provision-state decision matrix:
available / deploy failed (not maintenance) → target "active"; error → "rebuild"; active + --rebuild → "rebuild"; active without --rebuild → skipped with warning; maintenance=True → skipped
- Empty
instance_info with extra["instance_info"] JSON string → conn.baremetal.update_node called with the parsed dict (Ironic drops instance_info on undeploy)
validate_node raises openstack.exceptions.ValidationException → node skipped, set_node_provision_state not called
- NetBox: device found by name; found via
cf_inventory_hostname filter when name lookup returns None; local_context_data becomes the play vars with frr_parameters/netplan_parameters popped; NetBox lookup raising → warning, deploy continues with empty defaults
node.extra["netplan_parameters"] (JSON string) → vars merged, network_allow_service_restart=True, role osism.commons.network appended
node.extra["frr_parameters"] (JSON string) → deep_decrypt(frr_params, vault) called, frr_dummy_interface set from settings.FRR_DUMMY_INTERFACE, role osism.services.frr appended
- Vendor
" Supermicro " (whitespace/case-normalized) → set_node_boot_device(node.id, "cdrom", persistent=False); the call raising → warning only, deploy proceeds
target_raid_config set → deploy_steps with erase_devices_metadata (priority 95) and raid apply_configuration (priority 90, delete_existing=True) passed to set_node_provision_state; unset → deploy_steps=None
configdrive.pack raising → node skipped with warning, no provision call
set_node_provision_state raising → warning, loop continues with next node
--all → conn.baremetal.nodes(details=True) used instead of find_node
BaremetalDump.take_action() — baremetal.py:428 (gaps)
Patch as for Deploy (vault/decrypt, utils.nb, _get_metalbox_primary_ip4).
--ironic happy path: playbook YAML printed to stdout — parse with yaml.safe_load (capsys) and assert hostname_name == node.name, base roles osism.commons.hostname/hosts/operator, rsyslog restart task
--ironic with node.extra netplan/frr JSON strings → roles and vars extended as in Deploy
--ironic setup failure → returns 1
- NetBox default path happy: device by name and via
cf_inventory_hostname; local_context_data defaults with frr_parameters/netplan_parameters popped; custom_fields["netplan_parameters"] (already a dict, no json.loads) merged + network role; custom_fields["frr_parameters"] → deep_decrypt + frr role
- Exception during playbook generation → error logged, no raise
BaremetalUndeploy.take_action() — baremetal.py:709 (gaps)
Patch osism.commands.baremetal.cleanup_ssh_known_hosts_for_node (module-top import) plus the cloud helpers.
- Provision state in
["active", "wait call-back", "deploy failed", "error"] (parametrize) → set_node_provision_state(node.id, "undeploy") and cleanup_ssh_known_hosts_for_node(node.name) called; cleanup returns True → info log, False → warning
- Unsupported state (e.g.
available) → warning, no undeploy call
set_node_provision_state raising → warning, continue
--all --yes-i-really-really-mean-it → iterates conn.baremetal.nodes()
BaremetalPing._ping_host() — baremetal.py:811
Patch osism.commands.baremetal.subprocess.run. Pure output-parsing logic — highest-value target of the module.
returncode=0, stdout containing "0% packet loss" and a round-trip min/avg/max = 1.0/2.0/3.0 ms line → status == "SUCCESS", time_info is the part after "="
returncode=0 with "3 packets transmitted, 2 packets received, 33% packet loss" → status starts with "PARTIAL (" (third comma field)
returncode=0, no packet-loss line → SUCCESS, time_info == "N/A"
- Linux
rtt min/avg/max/mdev = ... line variant also parsed
returncode != 0 → FAILED / "N/A"
subprocess.TimeoutExpired side effect → ERROR, time_info truncated to 50 chars
- Result stored under
results[host_name] with keys host/status/time_info
BaremetalPing.take_action() — baremetal.py:860 (gaps)
Patch osism.utils.nb, osism.tasks.conductor.netbox.get_nb_device_query_list_ironic, osism.tasks.netbox.get_devices (both imported inside take_action — patch at source), and BaremetalPing._ping_host to avoid real pings/threads.
- No name → devices collected from all query-list entries, filtered to
custom_fields power_state == "power on" and provision_state == "active"; non-matching devices dropped
- No devices after filtering → info log, returns
None (not 1)
- Device without
primary_ip4 → warning, excluded; none left → info log, return
primary_ip4.address == "10.0.0.1/24" → ping candidate IP "10.0.0.1"
- Happy path: results table plus
"Summary: X successful, Y failed/partial out of N total" (capsys); PARTIAL counts as failed
BaremetalBurnIn.take_action() — baremetal.py:1012 (gaps)
- Default flags →
clean_steps == [{"step": "burnin_cpu", ...}, {"step": "burnin_memory", ...}, {"step": "burnin_disk", "interface": "deploy"}]; --no-disk removes only burnin_disk
available node → set_node_provision_state(node.id, "manage") then wait_for_nodes_provision_state([node.id], "manageable"); manage step raising → warning, node skipped
manageable node: instance_info refresh from extra (as in Deploy), Supermicro cdrom boot device, then set_node_provision_state(node.id, "clean", clean_steps=...); the clean call raising → warning, continue
active node without --yes-i-really-really-mean-it → error logged, no service call
active node with confirmation → node.set_provision_state(conn.baremetal, "service", service_steps=...) with burnin_disk filtered out and a data-loss warning logged
- Unsupported state (e.g.
enroll) → warning
BaremetalClean.take_action() — baremetal.py:1177 (gaps)
- Node with
raid_interface != "no-raid" → {"interface": "raid", "step": "delete_configuration"} prepended to erase_devices; "no-raid" → only erase_devices
available → manage → manageable flow incl. failure (as BurnIn)
manageable → set_node_provision_state(node.id, "clean", clean_steps=...) + success info; raising → warning, continue
- Unsupported state → warning
BaremetalProvide / BaremetalMaintenanceSet / BaremetalMaintenanceUnset / BaremetalPowerOn / BaremetalPowerOff — baremetal.py:1310, :1388, :1437, :1485, :1543 (happy paths)
One small parametrized block; the not-found paths are already covered.
- Provide:
manageable + not maintenance → set_node_provision_state(node.id, "provide"); maintenance=True → warning, no call; the call raising → warning
- MaintenanceSet with
--reason foo → set_node_maintenance(node, reason="foo"); raising → error logged, no raise
- MaintenanceUnset →
unset_node_maintenance(node); raising → error logged
- PowerOn →
set_node_power_state(node.id, "power on"); PowerOff default → "power off", --soft → "soft power off"; raising → error logged
BaremetalDelete.take_action() — baremetal.py:1611 (gaps)
Patch osism.utils.nb and osism.utils.secondary_nb_list via patch.dict("osism.utils.__dict__", ...).
- Ports from
conn.baremetal.ports(node_uuid=node.id) deleted before the node (delete_port(port.id, ignore_missing=True)); a single port deletion raising → warning, remaining ports and node still deleted
delete_node(node.id, ignore_missing=True) called
- Primary NetBox: device found →
custom_fields updated with {"provision_state": None, "power_state": None} and device.save() called; device not found → warning; lookup raising → warning, processing continues
secondary_nb_list with one entry → same clearing on the secondary; failure → warning only
utils.nb = None → no NetBox interaction, no crash
delete_node raising → error logged, loop continues with next node (--all path)
osism/commands/redfish.py — no existing tests
List._normalize_column_name() — redfish.py:12
"MAC Address" → "mac_address"; already-normalized input unchanged; empty string / None returned as-is
List._get_column_mappings() — redfish.py:18
"EthernetInterfaces" / "NetworkAdapters" / "NetworkDeviceFunctions" return the expected display-name → key dicts (spot-check a few entries)
- Unknown resource type →
None
List._get_filtered_columns() — redfish.py:57
selected_columns=None/empty → all headers and data keys returned
- Selection matches case-insensitively with spaces (
["mac address", "ID"] → headers ["ID", "MAC Address"] in mapping order)
- Unknown requested column → warning logged listing available columns, valid ones still returned
- Nothing matches →
([], [])
List._filter_json_data() — redfish.py:86
- Items reduced to the given keys; missing key →
None value
- Empty
data or empty data_keys → input returned unchanged
List._filter_and_display_table() — redfish.py:98
- Empty data → returns without printing
- All requested columns invalid → prints
"No valid columns specified"
- Happy path → grid table plus
"Total items: N" (capsys)
List.take_action() — redfish.py:147
Patch osism.utils.check_task_lock_and_exit and osism.tasks.conductor.get_redfish_resources (imported inside take_action). The Celery task is only used via .delay(...).get() — patch the task attribute with a MagicMock whose delay.return_value.get.return_value is fixture data; no broker needed.
check_task_lock_and_exit called before anything else
--format json, result present, no columns → full json.dumps(result, indent=2) (capsys)
--format json with --column and known resource type → only the selected keys in output
--format json with --column but unknown resource type (no mappings) → full dump
--format json, empty result → prints "[]"
- Table format dispatch: each of the three known resource types reaches its
_display_* helper (assert via output)
- Unknown resource type with result → info log
"Retrieved resources: ..."
- No result, table format →
"No {resourcetype} resources found for {hostname}"
osism/commands/server.py — gaps only
ServerMigrate.take_action() — server.py:54 (gaps)
Patch osism.tasks.openstack.get_cloud_helpers, osism.commands.server.prompt, osism.commands.server.time.sleep.
- ACTIVE server +
--yes → live_migrate_server(id, host=None, block_migration="auto", force=False)
--target host1 --force → passed through as host="host1", force=True
- Prompt answers:
"no" → no migration call; "y" → accepted
- Wait loop:
get_server side effect [MIGRATING, MIGRATING, ACTIVE] → polls until non-MIGRATING (patch time.sleep); --no-wait → get_server called exactly once (initial lookup)
- PAUSED status also allowed
ServerList.take_action() — server.py:151 (gaps)
--domain happy path: projects of the domain iterated, conn.compute.servers(all_projects=True, project_id=...) per project, 7-column table printed (capsys)
--project --project-domain: find_project called with domain_id query; domain name resolved via get_domain; get_domain raising → falls back to the raw domain_id
--user happy path: servers queried with user_id=...; per-server domain resolved via project; get_project/get_domain raising → domain_name stays None
- Default branch (no filters): only servers in
build/error older than 7200 s appear in the table (craft created_at timestamps on both sides of the threshold); fresh servers excluded
ServerClean.take_action() — server.py:403
Patch the cloud helpers and osism.commands.server.prompt.
- BUILD server older than
--build-timeout + --yes → delete_server(server.id, force=True)
- BUILD server younger than the timeout → not deleted, no prompt
- Custom
--build-timeout 60 honored
- Prompt answer
"no" → no deletion
- ERROR-status server prompts regardless of age;
--yes deletes
cleanup_cloud_environment called in finally
osism/commands/stress.py — no existing tests
OpenStackStress.take_action() — stress.py:167
Patch osism.tasks.openstack.get_cloud_helpers and osism.commands.stress.subprocess.run.
- Defaults → command starts with
["python3", "/openstack-simple-stress/openstack_simple_stress/main.py"], contains all integer/string defaults (--interval 10, --number 1, --cloud simple-stress, --flavor SCS-1V-2, --affinity soft-anti-affinity, --mode rolling, ...), and none of the boolean flags
- Boolean flags (
--no-cleanup --debug --no-delete --no-volume --no-boot-volume --no-wait --clean) each appended when set
- Custom values propagated (
--number 5 --flavor X --volume-size 10)
subprocess.run returncode passed through as exit code (test 0 and a non-zero value)
FileNotFoundError → returns 1, error log mentions the tool path
- Generic exception → returns 1
setup_cloud_environment failure → returns 1, subprocess.run never called
cleanup_cloud_environment called in all paths, including exceptions
Mocking hints
- Reuse the established pattern from
test_baremetal.py: get_cloud_helpers is imported inside every take_action, so patch osism.tasks.openstack.get_cloud_helpers returning a triple (setup, get_conn, cleanup) with setup = MagicMock(return_value=("pw", [], None, True)).
- Ironic node objects mix attribute access (
node.provision_state, node.extra), item access (node["maintenance"], node["target_raid_config"]), membership tests ("instance_info" in node) and node.get(...). A plain MagicMock does not support in/[] well — a small dict-backed fake class (attributes + __getitem__/__contains__/get) shared via a fixture pays off across all baremetal tests.
osism.utils.nb and osism.utils.secondary_nb_list are lazily initialized module attributes — patch via patch.dict("osism.utils.__dict__", {"nb": fake_nb, "secondary_nb_list": [fake_secondary]}) as the existing tests already do.
BaremetalDeploy/BaremetalDump (--ironic): patch openstack.baremetal.configdrive.pack (local import inside the function) and osism.tasks.conductor.utils.get_vault/deep_decrypt at their source module. Use the real openstack.exceptions.ValidationException as side_effect for the validation test.
redfish.List.take_action: get_redfish_resources is a Celery task, but the CLI only calls .delay(...).get(). Patching osism.tasks.conductor.get_redfish_resources with a MagicMock avoids any broker (consistent with the Tier-wide rule of never needing Celery infrastructure in unit tests).
- Output assertions (tabulate tables, JSON, YAML playbooks, summaries) via pytest's
capsys; for BaremetalDump, yaml.safe_load(capsys.readouterr().out) gives structural assertions instead of string matching.
BaremetalPing.take_action: patch BaremetalPing._ping_host directly when testing device filtering so no threads do real work; test the parsing logic separately via subprocess.run mocks.
ServerMigrate/ServerClean: prompt (prompt_toolkit) and time are module-top imports — patch osism.commands.server.prompt and osism.commands.server.time.sleep.
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 8 (#2199). This issue covers the hardware-related CLI command modules:
osism/commands/baremetal.py(1729 LOC, 14 cliff commands around Ironic node lifecycle),osism/commands/redfish.py(224 LOC, Redfish resource listing via a Celery task),osism/commands/server.py(454 LOC, Nova server migrate/list/clean), andosism/commands/stress.py(242 LOC, wrapper around theopenstack-simple-stresstool). Together ~2650 LOC — the largest CLI group in the meta issue.Scope
Extend
tests/unit/commands/test_baremetal.pyandtests/unit/commands/test_server.py; createtests/unit/commands/test_redfish.pyandtests/unit/commands/test_stress.py.Already covered — do not duplicate:
tests/unit/commands/test_baremetal.py: node-not-found → exit 1 for all ten node commands (Deploy, Undeploy, BurnIn, Clean, Provide, MaintenanceSet/Unset, PowerOn/Off, Delete);BaremetalDumpfailure paths (--ironicnode not found, NetBox unavailable, device not found);BaremetalPingfailure paths (NetBox unavailable, device not found, generic exception); missing-name argument validation for eight commands; burn-in with all stressors disabled;--allwithout--yes-i-really-really-mean-itfor Deploy(--rebuild)/Undeploy/Clean/Delete.tests/unit/commands/test_server.py:ServerListlookup failures (user-domain, user, domain, project-domain, project not found → exit 1);ServerMigraterefuses non-ACTIVE/PAUSED server.redfish.pyorstress.py.The remaining gaps are almost exclusively happy paths and state-machine branches. Given the size of
baremetal.py, prioritize the pure-logic targets (_apply_metalbox_vars,BaremetalPing._ping_host, clean-step construction, provision-state decision matrices) first; the simple wrapper commands (Provide/Maintenance/Power) are one parametrized block. This issue may be split further during implementation (e.g.baremetal.pyinto its own sub-issue, analogous to the Tier 3 splits).Test targets
osism/commands/baremetal.py— gaps only_apply_metalbox_vars()—baremetal.py:19Patch
osism.commands.baremetal._get_metalbox_primary_ip4(imported at module top)."192.168.30.1") →play_vars["hosts_additional_entries"] == {"metalbox.osism.xyz": "192.168.30.1"}andplay_vars["docker_insecure_registries"] == ["metalbox:5001"]None/falsy →play_varsleft untouchedBaremetalList.take_action()—baremetal.py:56Patch
osism.tasks.openstack.get_cloud_helpers(pattern from existing_run_not_found);osism.utils.nbviapatch.dict("osism.utils.__dict__", ...);osism.tasks.openstack.get_baremetal_node_netbox_infofor--netbox.setup_cloud_environmentreturnssuccess=False→ returns 1,get_openstack_connectionnever called (this guard is identical in every command of the module — one parametrized test over the command classes is enough)power_state=Nonerendered as"n/a"--provision-state active --maintenance→conn.baremetal.nodescalled withprovision_state="active", maintenance=True; no flags → called with no query kwargs--netboxwithutils.nbset →get_baremetal_node_netbox_infocalled per row,"Device Role"header inserted at index 1,device_role=None→"N/A"--netboxwithutils.nb = None→ no extra columncleanup_cloud_environmentcalled even whenconn.baremetal.nodesraises (finallyblock)BaremetalDeploy.take_action()—baremetal.py:157Patch:
osism.tasks.openstack.get_cloud_helpers,osism.tasks.conductor.utils.get_vault/osism.tasks.conductor.utils.deep_decrypt(imported insidetake_action— patch at source),openstack.baremetal.configdrive.pack(imported inside the loop — patch at source to avoid building a real config drive),osism.utils.nb,osism.commands.baremetal._get_metalbox_primary_ip4.available/deploy failed(not maintenance) → target"active";error→"rebuild";active+--rebuild→"rebuild";activewithout--rebuild→ skipped with warning;maintenance=True→ skippedinstance_infowithextra["instance_info"]JSON string →conn.baremetal.update_nodecalled with the parsed dict (Ironic dropsinstance_infoon undeploy)validate_noderaisesopenstack.exceptions.ValidationException→ node skipped,set_node_provision_statenot calledcf_inventory_hostnamefilter when name lookup returnsNone;local_context_databecomes the play vars withfrr_parameters/netplan_parameterspopped; NetBox lookup raising → warning, deploy continues with empty defaultsnode.extra["netplan_parameters"](JSON string) → vars merged,network_allow_service_restart=True, roleosism.commons.networkappendednode.extra["frr_parameters"](JSON string) →deep_decrypt(frr_params, vault)called,frr_dummy_interfaceset fromsettings.FRR_DUMMY_INTERFACE, roleosism.services.frrappended" Supermicro "(whitespace/case-normalized) →set_node_boot_device(node.id, "cdrom", persistent=False); the call raising → warning only, deploy proceedstarget_raid_configset →deploy_stepswitherase_devices_metadata(priority 95) and raidapply_configuration(priority 90,delete_existing=True) passed toset_node_provision_state; unset →deploy_steps=Noneconfigdrive.packraising → node skipped with warning, no provision callset_node_provision_stateraising → warning, loop continues with next node--all→conn.baremetal.nodes(details=True)used instead offind_nodeBaremetalDump.take_action()—baremetal.py:428(gaps)Patch as for Deploy (vault/decrypt,
utils.nb,_get_metalbox_primary_ip4).--ironichappy path: playbook YAML printed to stdout — parse withyaml.safe_load(capsys) and asserthostname_name == node.name, base rolesosism.commons.hostname/hosts/operator, rsyslog restart task--ironicwithnode.extranetplan/frr JSON strings → roles and vars extended as in Deploy--ironicsetup failure → returns 1cf_inventory_hostname;local_context_datadefaults withfrr_parameters/netplan_parameterspopped;custom_fields["netplan_parameters"](already a dict, nojson.loads) merged + network role;custom_fields["frr_parameters"]→deep_decrypt+ frr roleBaremetalUndeploy.take_action()—baremetal.py:709(gaps)Patch
osism.commands.baremetal.cleanup_ssh_known_hosts_for_node(module-top import) plus the cloud helpers.["active", "wait call-back", "deploy failed", "error"](parametrize) →set_node_provision_state(node.id, "undeploy")andcleanup_ssh_known_hosts_for_node(node.name)called; cleanup returnsTrue→ info log,False→ warningavailable) → warning, no undeploy callset_node_provision_stateraising → warning, continue--all --yes-i-really-really-mean-it→ iteratesconn.baremetal.nodes()BaremetalPing._ping_host()—baremetal.py:811Patch
osism.commands.baremetal.subprocess.run. Pure output-parsing logic — highest-value target of the module.returncode=0, stdout containing"0% packet loss"and around-trip min/avg/max = 1.0/2.0/3.0 msline →status == "SUCCESS",time_infois the part after"="returncode=0with"3 packets transmitted, 2 packets received, 33% packet loss"→ status starts with"PARTIAL ("(third comma field)returncode=0, no packet-loss line →SUCCESS,time_info == "N/A"rtt min/avg/max/mdev = ...line variant also parsedreturncode != 0→FAILED/"N/A"subprocess.TimeoutExpiredside effect →ERROR,time_infotruncated to 50 charsresults[host_name]with keyshost/status/time_infoBaremetalPing.take_action()—baremetal.py:860(gaps)Patch
osism.utils.nb,osism.tasks.conductor.netbox.get_nb_device_query_list_ironic,osism.tasks.netbox.get_devices(both imported insidetake_action— patch at source), andBaremetalPing._ping_hostto avoid real pings/threads.custom_fieldspower_state == "power on"andprovision_state == "active"; non-matching devices droppedNone(not 1)primary_ip4→ warning, excluded; none left → info log, returnprimary_ip4.address == "10.0.0.1/24"→ ping candidate IP"10.0.0.1""Summary: X successful, Y failed/partial out of N total"(capsys); PARTIAL counts as failedBaremetalBurnIn.take_action()—baremetal.py:1012(gaps)clean_steps == [{"step": "burnin_cpu", ...}, {"step": "burnin_memory", ...}, {"step": "burnin_disk", "interface": "deploy"}];--no-diskremoves onlyburnin_diskavailablenode →set_node_provision_state(node.id, "manage")thenwait_for_nodes_provision_state([node.id], "manageable"); manage step raising → warning, node skippedmanageablenode:instance_inforefresh fromextra(as in Deploy), Supermicro cdrom boot device, thenset_node_provision_state(node.id, "clean", clean_steps=...); the clean call raising → warning, continueactivenode without--yes-i-really-really-mean-it→ error logged, no service callactivenode with confirmation →node.set_provision_state(conn.baremetal, "service", service_steps=...)withburnin_diskfiltered out and a data-loss warning loggedenroll) → warningBaremetalClean.take_action()—baremetal.py:1177(gaps)raid_interface != "no-raid"→{"interface": "raid", "step": "delete_configuration"}prepended toerase_devices;"no-raid"→ onlyerase_devicesavailable→ manage → manageable flow incl. failure (as BurnIn)manageable→set_node_provision_state(node.id, "clean", clean_steps=...)+ success info; raising → warning, continueBaremetalProvide/BaremetalMaintenanceSet/BaremetalMaintenanceUnset/BaremetalPowerOn/BaremetalPowerOff—baremetal.py:1310,:1388,:1437,:1485,:1543(happy paths)One small parametrized block; the not-found paths are already covered.
manageable+ not maintenance →set_node_provision_state(node.id, "provide");maintenance=True→ warning, no call; the call raising → warning--reason foo→set_node_maintenance(node, reason="foo"); raising → error logged, no raiseunset_node_maintenance(node); raising → error loggedset_node_power_state(node.id, "power on"); PowerOff default →"power off",--soft→"soft power off"; raising → error loggedBaremetalDelete.take_action()—baremetal.py:1611(gaps)Patch
osism.utils.nbandosism.utils.secondary_nb_listviapatch.dict("osism.utils.__dict__", ...).conn.baremetal.ports(node_uuid=node.id)deleted before the node (delete_port(port.id, ignore_missing=True)); a single port deletion raising → warning, remaining ports and node still deleteddelete_node(node.id, ignore_missing=True)calledcustom_fieldsupdated with{"provision_state": None, "power_state": None}anddevice.save()called; device not found → warning; lookup raising → warning, processing continuessecondary_nb_listwith one entry → same clearing on the secondary; failure → warning onlyutils.nb = None→ no NetBox interaction, no crashdelete_noderaising → error logged, loop continues with next node (--allpath)osism/commands/redfish.py— no existing testsList._normalize_column_name()—redfish.py:12"MAC Address"→"mac_address"; already-normalized input unchanged; empty string /Nonereturned as-isList._get_column_mappings()—redfish.py:18"EthernetInterfaces"/"NetworkAdapters"/"NetworkDeviceFunctions"return the expected display-name → key dicts (spot-check a few entries)NoneList._get_filtered_columns()—redfish.py:57selected_columns=None/empty → all headers and data keys returned["mac address", "ID"]→ headers["ID", "MAC Address"]in mapping order)([], [])List._filter_json_data()—redfish.py:86Nonevaluedataor emptydata_keys→ input returned unchangedList._filter_and_display_table()—redfish.py:98"No valid columns specified""Total items: N"(capsys)List.take_action()—redfish.py:147Patch
osism.utils.check_task_lock_and_exitandosism.tasks.conductor.get_redfish_resources(imported insidetake_action). The Celery task is only used via.delay(...).get()— patch the task attribute with aMagicMockwhosedelay.return_value.get.return_valueis fixture data; no broker needed.check_task_lock_and_exitcalled before anything else--format json, result present, no columns → fulljson.dumps(result, indent=2)(capsys)--format jsonwith--columnand known resource type → only the selected keys in output--format jsonwith--columnbut unknown resource type (no mappings) → full dump--format json, empty result → prints"[]"_display_*helper (assert via output)"Retrieved resources: ...""No {resourcetype} resources found for {hostname}"osism/commands/server.py— gaps onlyServerMigrate.take_action()—server.py:54(gaps)Patch
osism.tasks.openstack.get_cloud_helpers,osism.commands.server.prompt,osism.commands.server.time.sleep.--yes→live_migrate_server(id, host=None, block_migration="auto", force=False)--target host1 --force→ passed through ashost="host1", force=True"no"→ no migration call;"y"→ acceptedget_serverside effect[MIGRATING, MIGRATING, ACTIVE]→ polls until non-MIGRATING (patchtime.sleep);--no-wait→get_servercalled exactly once (initial lookup)ServerList.take_action()—server.py:151(gaps)--domainhappy path: projects of the domain iterated,conn.compute.servers(all_projects=True, project_id=...)per project, 7-column table printed (capsys)--project --project-domain:find_projectcalled withdomain_idquery; domain name resolved viaget_domain;get_domainraising → falls back to the rawdomain_id--userhappy path: servers queried withuser_id=...; per-server domain resolved via project;get_project/get_domainraising →domain_namestaysNonebuild/errorolder than 7200 s appear in the table (craftcreated_attimestamps on both sides of the threshold); fresh servers excludedServerClean.take_action()—server.py:403Patch the cloud helpers and
osism.commands.server.prompt.--build-timeout+--yes→delete_server(server.id, force=True)--build-timeout 60honored"no"→ no deletion--yesdeletescleanup_cloud_environmentcalled infinallyosism/commands/stress.py— no existing testsOpenStackStress.take_action()—stress.py:167Patch
osism.tasks.openstack.get_cloud_helpersandosism.commands.stress.subprocess.run.["python3", "/openstack-simple-stress/openstack_simple_stress/main.py"], contains all integer/string defaults (--interval 10,--number 1,--cloud simple-stress,--flavor SCS-1V-2,--affinity soft-anti-affinity,--mode rolling, ...), and none of the boolean flags--no-cleanup --debug --no-delete --no-volume --no-boot-volume --no-wait --clean) each appended when set--number 5 --flavor X --volume-size 10)subprocess.runreturncode passed through as exit code (test 0 and a non-zero value)FileNotFoundError→ returns 1, error log mentions the tool pathsetup_cloud_environmentfailure → returns 1,subprocess.runnever calledcleanup_cloud_environmentcalled in all paths, including exceptionsMocking hints
test_baremetal.py:get_cloud_helpersis imported inside everytake_action, so patchosism.tasks.openstack.get_cloud_helpersreturning a triple(setup, get_conn, cleanup)withsetup = MagicMock(return_value=("pw", [], None, True)).node.provision_state,node.extra), item access (node["maintenance"],node["target_raid_config"]), membership tests ("instance_info" in node) andnode.get(...). A plainMagicMockdoes not supportin/[]well — a small dict-backed fake class (attributes +__getitem__/__contains__/get) shared via a fixture pays off across all baremetal tests.osism.utils.nbandosism.utils.secondary_nb_listare lazily initialized module attributes — patch viapatch.dict("osism.utils.__dict__", {"nb": fake_nb, "secondary_nb_list": [fake_secondary]})as the existing tests already do.BaremetalDeploy/BaremetalDump(--ironic): patchopenstack.baremetal.configdrive.pack(local import inside the function) andosism.tasks.conductor.utils.get_vault/deep_decryptat their source module. Use the realopenstack.exceptions.ValidationExceptionasside_effectfor the validation test.redfish.List.take_action:get_redfish_resourcesis a Celery task, but the CLI only calls.delay(...).get(). Patchingosism.tasks.conductor.get_redfish_resourceswith aMagicMockavoids any broker (consistent with the Tier-wide rule of never needing Celery infrastructure in unit tests).capsys; forBaremetalDump,yaml.safe_load(capsys.readouterr().out)gives structural assertions instead of string matching.BaremetalPing.take_action: patchBaremetalPing._ping_hostdirectly when testing device filtering so no threads do real work; test the parsing logic separately viasubprocess.runmocks.ServerMigrate/ServerClean:prompt(prompt_toolkit) andtimeare module-top imports — patchosism.commands.server.promptandosism.commands.server.time.sleep.Definition of Done
tests/unit/commands/test_redfish.pyandtests/unit/commands/test_stress.pycreatedtests/unit/commands/test_baremetal.pyandtests/unit/commands/test_server.pyextended (no duplication of existing cases)osism.commands.baremetal≥ 80 %,osism.commands.server≥ 90 %,osism.commands.redfish≥ 90 %,osism.commands.stress≥ 95 %pipenv run pytest tests/unit/commands/test_baremetal.py tests/unit/commands/test_redfish.py tests/unit/commands/test_server.py tests/unit/commands/test_stress.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies
baremetal.pyas its own sub-issue) — see note in Scope.