| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Tests for the implicit max_nodes rollback on a rejected node count update.""" | |
| import os | |
| import pytest | |
| import requests | |
| import atf | |
| test_name = os.path.splitext(os.path.basename(__file__))[0] | |
| TASKS = 8 | |
| IMPLICIT_MAX_NODES = 8 | |
| MIN_NODES = 2 | |
| UPDATE_TASKS_PER_NODE = 4 | |
| REDERIVED_MAX_NODES = 2 | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| # slurmrestd is upgraded with the client commands, so it is never newer | |
| # than slurmctld and this also gates the implicit max_nodes support | |
| atf.require_version( | |
| (26, 5, 3), | |
| "sbin/slurmrestd", | |
| reason="Ticket 25443: data_parser v0.0.45 was added in 26.05 and the implicit max_nodes rederive in 26.05.3", | |
| ) | |
| atf.require_config_parameter_includes("AuthAltTypes", "auth/jwt") | |
| atf.require_slurmrestd("slurmctld,util", "v0.0.45") | |
| atf.require_nodes(2, [("CPUs", 8), ("RealMemory", 1024)]) | |
| atf.require_slurm_running() | |
| def test_rejected_max_nodes_update_keeps_implicit_bound(): | |
| """Verify a rejected node count update leaves the implicit max_nodes intact""" | |
| job_id = atf.submit_job_sbatch( | |
| f'--hold -n{TASKS} -J {test_name} -t 1 --wrap "hostname" -o /dev/null', | |
| fatal=True, | |
| ) | |
| max_nodes = atf.get_jobs(job_id, use_json=True, fatal=True)[job_id]["max_nodes"][ | |
| "number" | |
| ] | |
| assert ( | |
| max_nodes == IMPLICIT_MAX_NODES | |
| ), f"max_nodes should start implicitly derived as {IMPLICIT_MAX_NODES}" | |
| # Raising only the minimum leaves max_nodes implicitly derived, which is | |
| # what lets the next update be rejected by slurmctld | |
| response = requests.post( | |
| f"{atf.properties['slurmrestd_url']}/slurm/v0.0.45/job/{job_id}", | |
| headers=atf.properties["slurmrestd-headers"], | |
| json={"minimum_nodes": MIN_NODES}, | |
| ) | |
| assert ( | |
| response.status_code == 200 | |
| ), f"Raising minimum_nodes should return HTTP 200, got {response.status_code}" | |
| errors = response.json().get("errors") | |
| assert not errors, f"Raising minimum_nodes should succeed: {errors}" | |
| # slurmrestd can set maximum_nodes without minimum_nodes, which scontrol | |
| # cannot, so this is the only way to have slurmctld reject the update | |
| response = requests.post( | |
| f"{atf.properties['slurmrestd_url']}/slurm/v0.0.45/job/{job_id}", | |
| headers=atf.properties["slurmrestd-headers"], | |
| json={"maximum_nodes": MIN_NODES - 1}, | |
| ) | |
| assert response.headers.get("content-type", "").startswith( | |
| "application/json" | |
| ), f"Expected a JSON response, got {response.headers.get('content-type')}" | |
| assert response.json().get( | |
| "errors" | |
| ), "A maximum_nodes below the minimum should be rejected" | |
| atf.run_command( | |
| f"scontrol update jobid={job_id} TasksPerNode={UPDATE_TASKS_PER_NODE}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| max_nodes = atf.get_jobs(job_id, use_json=True, fatal=True)[job_id]["max_nodes"][ | |
| "number" | |
| ] | |
| assert max_nodes == REDERIVED_MAX_NODES, ( | |
| f"max_nodes should be rederived as " | |
| f"ROUNDUP({TASKS}, {UPDATE_TASKS_PER_NODE})={REDERIVED_MAX_NODES}, " | |
| f"but is {max_nodes}" | |
| ) |