| ############################################################################ | |
| # Copyright (C) SchedMD LLC. | |
| ############################################################################ | |
| import os | |
| import re | |
| import time | |
| import pytest | |
| import atf | |
| test_name = os.path.splitext(os.path.basename(__file__))[0] | |
| part_name = f"{test_name}_partition" | |
| feature_list = ["f1", "f2", "f3"] | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| atf.require_nodes(6) | |
| atf.require_slurm_running() | |
| def _extract_nodenames(s: str, max_count: int | None = None) -> list[str]: | |
| names: list[str] = [] | |
| for v in re.compile(r"^\s*NodeName=([^\s]+)", re.M).findall(s): | |
| v = v.strip() | |
| names.append(v) | |
| return names | |
| def _create_partition(part_name: str = "res_part", count: int = 4) -> list[str]: | |
| out = atf.run_command_output("scontrol show nodes") | |
| picked = _extract_nodenames(out, max_count=count) | |
| nodelist = ",".join(picked) | |
| atf.run_command( | |
| f"scontrol create partitionname={part_name} Nodes={nodelist}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| return picked | |
| def _delete_partition(part_name): | |
| atf.run_command( | |
| f"scontrol delete partitionname={part_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| def _extract_features(s: str) -> str | None: | |
| m = re.search(r"(?:ActiveFeatures)=([^\s]+)", s) | |
| if not m: | |
| return None | |
| v = m.group(1) | |
| if v.lower() in ("(null)", "none"): | |
| return None | |
| return v | |
| def _get_features(node): | |
| out = atf.run_command_output(f"scontrol show node {node}") | |
| return _extract_features(out) or [] | |
| def _clear_res_features(nodes): | |
| for i, node in enumerate(nodes): | |
| atf.run_command( | |
| f"scontrol update nodename={node} " | |
| f"availablefeatures= " | |
| f"activefeatures= ", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| def _extract_nodes_raw(s: str) -> str | None: | |
| m = re.search(r"(?:Nodes)=([^\s]+)", s) | |
| if not m: | |
| return None | |
| v = m.group(1) | |
| if v.lower() in ("(null)", "none"): | |
| return None | |
| return v | |
| def _expand_hostlist(hostlist: str) -> set[str]: | |
| if not hostlist: | |
| return set() | |
| out = atf.run_command_output(f"scontrol show hostnames {hostlist}") | |
| return {ln.strip() for ln in out.splitlines() if ln.strip()} | |
| def _nodes_from_res(resv_name: str) -> set[str]: | |
| show = atf.run_command_output(f"scontrol show res {resv_name}") | |
| return _expand_hostlist(_extract_nodes_raw(show)) | |
| def _down_one_node_and_wait_for_replacement( | |
| resv_name: str, | |
| current_nodes: set[str], | |
| target: str, | |
| timeout_s: int = 60, | |
| poll_interval_s: float = 2.0, | |
| ): | |
| """ | |
| DOWN one node from the reservation and wait for a change in the set. | |
| Returns (new_nodes_set, replacements_set). | |
| """ | |
| atf.run_command( | |
| f"scontrol update nodename={target} state=DOWN reason=HOLD", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| deadline = time.time() + timeout_s | |
| while time.time() < deadline: | |
| time.sleep(poll_interval_s) | |
| updated = _nodes_from_res(resv_name) | |
| if updated != current_nodes: | |
| return updated, (updated - current_nodes) | |
| print( | |
| f"{resv_name}: node set did not change after DOWNing {target} within {timeout_s}s; " | |
| f"still: {sorted(_nodes_from_res(resv_name))} - this may be OK" | |
| ) | |
| return current_nodes, set() | |
| def _bring_node_up(node: str): | |
| try: | |
| atf.run_command( | |
| f"scontrol update nodename={node} state=RESUME", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| except Exception: | |
| pass | |
| def _check_nodes_for_features(name, nodes): | |
| if len(nodes) != len(feature_list): | |
| pytest.fail( | |
| f"{name} Did not allocate correct number of nodes. " | |
| f"Expected={len(feature_list)} Nodes={sorted(nodes)}" | |
| ) | |
| # Pre-init counts so all features are present even if 0 | |
| counts = {f: 0 for f in feature_list} | |
| for n in nodes: | |
| features = _get_features(n) | |
| matched = [f for f in feature_list if f in features] | |
| if len(matched) != 1: | |
| pytest.fail( | |
| f"{name} Node {n} matched {matched} " f"features (expected exactly 1)." | |
| ) | |
| counts[matched[0]] += 1 | |
| missing = [f for f, c in counts.items() if c == 0] | |
| extra = [f for f, c in counts.items() if c > 1] | |
| if missing or extra: | |
| pytest.fail( | |
| f"{name} Feature assignment wrong. " | |
| f"Missing={missing} Extra={extra} Counts={counts}" | |
| ) | |
| @pytest.mark.xfail( | |
| atf.get_version() < (25, 11), | |
| reason="Ticket 23175: When REPLACE_DOWN flag is set for xand feature selected reservation, nodes are over-selected when replacing", | |
| ) | |
| def test_reservation_by_feature_and_replace(): | |
| """ | |
| This only tests the simple case where the reservation expects | |
| 1 node for each feature, and the one node that DOWNs is | |
| expected to be replaced with a node with the same feature. | |
| 1) Assign features to nodes from partition | |
| 2) Create a reservation by features and node count | |
| 3) Check features/nodes allocation is as expected | |
| 4) DOWN one node, wait for replacement | |
| 5) Check features/nodes allocation is as expected | |
| """ | |
| downed_node = None | |
| try: | |
| # Create partition and get nodes | |
| part_nodes = _create_partition(part_name, 4) | |
| # Assign features to pairs of nodes | |
| f_len = len(feature_list) | |
| for i, node in enumerate(part_nodes): | |
| atf.run_command( | |
| f"scontrol update nodename={node} " | |
| f"availablefeatures={feature_list[i % f_len]} " | |
| f"activefeatures={feature_list[i % f_len]} ", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| # Create reservation | |
| res_name = f"{test_name}_res" | |
| atf.run_command( | |
| f"scontrol create reservation reservationname={res_name} " | |
| f"user={atf.properties['test-user']} start=now duration=1 " | |
| f"partition={part_name} " | |
| f"features='[{'&'.join(s + '*1' for s in feature_list)}]' " | |
| f"NodeCnt={f_len} flags=REPLACE_DOWN", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| # give it some time to populate | |
| time.sleep(5) | |
| res_nodes = _nodes_from_res(res_name) | |
| print(res_nodes) | |
| # log the node reservation state | |
| atf.run_command( | |
| "sinfo -l", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| # Something went wrong, there are no assigned nodes | |
| assert res_nodes, f"{res_name}: no nodes assigned" | |
| ## Verify allocated nodes for each feature | |
| _check_nodes_for_features(res_name, res_nodes) | |
| # Get node to down | |
| downed_node = next(iter(res_nodes)) | |
| # DOWN a node and wait for replacement | |
| new_nodes, replacements = _down_one_node_and_wait_for_replacement( | |
| res_name, res_nodes, downed_node, timeout_s=5, poll_interval_s=2.0 | |
| ) | |
| if not replacements: | |
| pytest.fail( | |
| f"{res_name}: no replacement detected after DOWNing {downed_node}. " | |
| f"Old={sorted(res_nodes)} New={sorted(new_nodes)}" | |
| ) | |
| ## Verify allocated nodes for each feature | |
| res_nodes = _nodes_from_res(res_name) | |
| print(res_nodes) | |
| _check_nodes_for_features(res_name, res_nodes) | |
| finally: | |
| try: | |
| atf.run_command( | |
| f"scontrol delete reservationname={res_name}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| except Exception: | |
| pass | |
| if downed_node: | |
| _bring_node_up(downed_node) | |
| _clear_res_features(part_nodes) | |
| _delete_partition(part_name) |