| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """ | |
| Cloud node first registration binds count-only GRES jobs in place. | |
| Submit a job while the CLOUD node is still powered down (no slurmd state on | |
| the controller), then start slurmd via ResumeProgram for first registration. | |
| ResumeProgram writes an AutoDetect-only gres.conf; slurmd discovers device | |
| topology from fake_gpus.conf. The count-only GRES allocation is then bound to | |
| concrete devices in place: the job stays CONFIGURING and proceeds straight to | |
| RUNNING -- it is never de-allocated back to PENDING. Pre-fix slurmctld could | |
| fail validation with "count changed and jobs are using them", draining the | |
| node instead. | |
| Both untyped (gpu:N) and typed (gpu:<type>:N) requests are exercised; the | |
| deferred allocation is completed by re-running the real allocator, so typed | |
| GRES binds in place too. Hetjobs are out of scope. | |
| A separate test verifies that ConstrainDevices enforces exactly the bound GPUs | |
| on a job step, when cgroup device BPF is available on the host. | |
| """ | |
| import logging | |
| import re | |
| from pathlib import Path | |
| import pytest | |
| import atf | |
| pytestmark = pytest.mark.slow | |
| node_prefix = "node" | |
| cloud_node = f"{node_prefix}1" | |
| cloud_feature = "f1" | |
| gpu_count = 4 | |
| gpus_requested = 2 | |
| # Typed GPUs (declared in slurm.conf and discovered from fake_gpus.conf) so the | |
| # count-only-then-bind path is exercised for a typed request too. | |
| gpu_type = "gp100" | |
| # A consumable GRES with no device File anywhere: count-only by design, so it | |
| # must never be mistaken for a deferred bind that failed. | |
| fileless_gres = "bandwidth" | |
| fileless_count = 100 | |
| fileless_requested = 10 | |
| suspend_time = 10 | |
| # slurmd -b in ResumeProgram blocks until registration; AutoDetect needs headroom. | |
| suspend_timeout = 100 | |
| resume_timeout = 100 | |
| slurmd_bin = atf.properties["slurm-sbin-dir"] + "/slurmd" | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(request): | |
| atf.require_version( | |
| (26, 11), | |
| "sbin/slurmctld", | |
| reason="Ticket 25123: Cloud GRES topology bind-in-place on node registration added in 26.11", | |
| ) | |
| atf.get_run_dir_path() | |
| atf.require_config_parameter("SelectType", "select/cons_tres") | |
| atf.require_config_parameter("SelectTypeParameters", "CR_CPU") | |
| atf.require_config_parameter("TreeWidth", 65533) | |
| atf.require_config_parameter( | |
| "ResumeProgram", resume_ctld_script(f"{atf.module_tmp_path}/def_resume.sh") | |
| ) | |
| atf.require_config_parameter( | |
| "SuspendProgram", suspend_ctld_script(f"{atf.module_tmp_path}/def_suspend.sh") | |
| ) | |
| atf.require_config_parameter("SuspendTime", suspend_time) | |
| atf.require_config_parameter("SuspendTimeout", suspend_timeout) | |
| atf.require_config_parameter("ResumeTimeout", resume_timeout) | |
| atf.require_config_parameter_includes("SlurmctldParameters", "idle_on_node_suspend") | |
| # So the terminal (resume-failure) path requeues the job instead of killing | |
| # it, giving a deterministic PENDING outcome to assert. | |
| atf.require_config_parameter_includes( | |
| "SchedulerParameters", "requeue_on_resume_failure" | |
| ) | |
| atf.require_config_parameter_includes("GresTypes", "gpu") | |
| atf.require_config_parameter_includes("GresTypes", fileless_gres) | |
| atf.require_config_parameter("ProctrackType", "proctrack/cgroup") | |
| atf.require_config_parameter_includes("TaskPlugin", "cgroup") | |
| atf.require_config_parameter("ConstrainDevices", "yes", source="cgroup") | |
| # task/cgroup reports an inactive device cgroup only under this flag; without | |
| # it _assert_step_device_constraint() cannot tell the operator *why* device | |
| # constraining is not being enforced on this host. | |
| atf.require_config_parameter_includes("DebugFlags", "Cgroup") | |
| _ensure_gres_devices() | |
| # Register the removal now rather than after the yield below: if anything | |
| # in the rest of this fixture raises (e.g. start_slurmctld), the post-yield | |
| # code never runs and the root-owned device nodes leak into the pytest tmp | |
| # tree, where ATF's cleanup cannot unlink them as the test user. | |
| request.addfinalizer(_remove_gres_devices) | |
| # Count-only on slurmctld until ResumeProgram writes AutoDetect gres.conf. | |
| _write_gres_conf("") | |
| _write_fake_gpus_conf("") | |
| atf.require_config_parameter( | |
| "NodeName", | |
| { | |
| cloud_node: { | |
| "Feature": cloud_feature, | |
| "State": "CLOUD", | |
| "Gres": f"gpu:{gpu_type}:{gpu_count},{fileless_gres}:{fileless_count}", | |
| } | |
| }, | |
| ) | |
| atf.require_config_parameter("Nodeset", {"ns1": {"Feature": cloud_feature}}) | |
| atf.require_config_parameter( | |
| "PartitionName", | |
| { | |
| "primary": {"Nodes": "ALL"}, | |
| # YES only shares CPUs with jobs that ask (--oversubscribe). | |
| "cloud1": {"Nodes": "ns1", "OverSubscribe": "YES"}, | |
| }, | |
| ) | |
| _ensure_node_spool_dirs(cloud_node) | |
| atf.start_slurmctld(clean=True) | |
| yield | |
| atf.stop_slurmctld(also_slurmds=True) | |
| def _gpu_dev_base(): | |
| return f"{atf.module_tmp_path}/gpu" | |
| def _fake_gpus_conf_lines(): | |
| lines = [] | |
| for i in range(gpu_count): | |
| path = f"{_gpu_dev_base()}{i}" | |
| # Fields are type|sys_cpu_count|cpu_range|links|device_file|unique_id| | |
| # flags (see gpu_g_get_system_gpu_list()). cpu_range is left unset so | |
| # the GPU is not tied to specific cores on the node. | |
| lines.append(f"{gpu_type}|2|(null)|(null)|{path}|(null)|nvidia_gpu_env") | |
| return "\n".join(lines) | |
| def _write_fake_gpus_conf(content, fatal=True): | |
| path = f"{atf.properties['slurm-config-dir']}/fake_gpus.conf" | |
| atf.run_command( | |
| f"cat > {path}", | |
| input=content, | |
| user=atf.properties["slurm-user"], | |
| fatal=fatal, | |
| quiet=True, | |
| ) | |
| def _ensure_gres_devices(): | |
| """ | |
| Fake GRES device nodes. Major 1 (mem) with an unassigned minor (50+) has no | |
| backing driver, so a step *allowed* to open it by the device cgroup gets | |
| ENXIO ("No such device or address"), while a step *denied* by the cgroup | |
| gets EPERM ("Operation not permitted"). That errno difference is how | |
| _assert_step_device_constraint() tells bound devices from constrained ones | |
| without real GPUs. | |
| """ | |
| for i in range(gpu_count): | |
| path = f"{_gpu_dev_base()}{i}" | |
| atf.run_command( | |
| f"rm -f {path}; mknod -m 666 {path} c 1 {50 + i}", | |
| user="root", | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| def _remove_gres_devices(): | |
| """Remove the root-owned fake device nodes. | |
| ATF's tmp cleanup runs as the test user and cannot unlink them. | |
| """ | |
| for i in range(gpu_count): | |
| atf.run_command(f"rm -f {_gpu_dev_base()}{i}", user="root", quiet=True) | |
| script_preamble = """ | |
| SCRIPT_DIR="$( cd -- "$( dirname -- "${{BASH_SOURCE[0]:-$0}}"; )" &> /dev/null && pwd 2> /dev/null; )"; | |
| exec &> >(tee -a $SCRIPT_DIR/{script_name}.log) | |
| PS4='+ $(date "+%y-%m-%dT%H:%M:%S") ($SLURM_NODE_NAME)\011 ' | |
| echo $@ | |
| set -x | |
| """ | |
| def _count_only_marker(): | |
| # When this marker exists, ResumeProgram registers count-only GRES (no | |
| # device File topology) to exercise the terminal/resume-failure path. | |
| return f"{atf.module_tmp_path}/count_only_gres" | |
| def resume_ctld_script(path): | |
| gres_conf = f"{atf.properties['slurm-config-dir']}/gres.conf" | |
| fake_gpus_conf = f"{atf.properties['slurm-config-dir']}/fake_gpus.conf" | |
| fake_lines = _fake_gpus_conf_lines() | |
| content = f""" | |
| SLURM_NODE_NAME=$1 | |
| {script_preamble.format(script_name=Path(path).stem)} | |
| sleep 2 # wait for slurmctld to update node state | |
| if [ -f {_count_only_marker()} ]; then | |
| # Register the configured GPU count but no device File topology, so the | |
| # controller cannot bind the count-only allocation to devices. | |
| cat > {gres_conf} <<'GRES_EOF' | |
| Name=gpu Type={gpu_type} Count={gpu_count} | |
| GRES_EOF | |
| else | |
| cat > {fake_gpus_conf} <<'FAKE_EOF' | |
| {fake_lines} | |
| FAKE_EOF | |
| cat > {gres_conf} <<'GRES_EOF' | |
| AutoDetect=nvidia | |
| GRES_EOF | |
| fi | |
| for node in $({atf.properties["slurm-bin-dir"]}/scontrol show hostname $SLURM_NODE_NAME); do | |
| sudo {slurmd_bin} -N $node -b --conf 'feature={cloud_feature}' | |
| done | |
| """ | |
| atf.make_bash_script(path, content) | |
| return path | |
| def suspend_ctld_script(path): | |
| pidfile_template = atf.get_run_dir_path() + "/slurmd.$node.pid" | |
| content = f""" | |
| SLURM_NODE_NAME=$1 | |
| {script_preamble.format(script_name=Path(path).stem)} | |
| for node in $({atf.properties["slurm-bin-dir"]}/scontrol show hostname $SLURM_NODE_NAME); do | |
| sudo pkill -F {pidfile_template} | |
| done | |
| """ | |
| atf.make_bash_script(path, content) | |
| return path | |
| def _write_gres_conf(content, fatal=True): | |
| path = f"{atf.properties['slurm-config-dir']}/gres.conf" | |
| atf.run_command( | |
| f"cat > {path}", | |
| input=content, | |
| user=atf.properties["slurm-user"], | |
| fatal=fatal, | |
| quiet=True, | |
| ) | |
| def _ensure_node_spool_dirs(node_name): | |
| spool_dir = atf.properties["slurm-spool-dir"].replace("%n", node_name) | |
| tmpfs_dir = atf.properties["slurm-tmpfs"].replace("%n", node_name) | |
| atf.run_command( | |
| f"mkdir -p {spool_dir} {tmpfs_dir}", user="root", fatal=True, quiet=True | |
| ) | |
| # conftest removes these after the module only for nodes listed here. | |
| if node_name not in atf.properties["nodes"]: | |
| atf.properties["nodes"].append(node_name) | |
| @pytest.fixture(scope="module") | |
| def device_cgroup_testable(): | |
| """Skip when the fake GPU device nodes cannot be opened at all. | |
| _assert_step_device_constraint() distinguishes a cgroup-allowed device from | |
| a cgroup-denied one by errno, which only works if an *unconstrained* open of | |
| a fake node reaches the (absent) driver and fails with ENXIO. If the pytest | |
| tmp filesystem is mounted 'nodev' the kernel rejects every open with EACCES | |
| ("Permission denied") instead, which is indistinguishable from a cgroup | |
| denial and would make the errno pattern meaningless. Probe once here, | |
| outside of any job, rather than hard-failing on an unclassifiable errno. | |
| """ | |
| probe = f"{_gpu_dev_base()}0" | |
| result = atf.run_command(f"cat {probe}", quiet=True) | |
| err = (result["stderr"] or "").strip() | |
| if "No such device or address" not in err: | |
| pytest.skip( | |
| f"Cannot tell cgroup-denied from cgroup-allowed GPU devices here: " | |
| f"opening {probe} outside of any cgroup reported {err!r} instead of " | |
| f"ENXIO. The filesystem holding {atf.module_tmp_path} is likely " | |
| f"mounted 'nodev'. Run on a host where device nodes can be opened " | |
| f"there." | |
| ) | |
| @pytest.fixture(scope="function", autouse=True) | |
| def cloud_state(): | |
| """Stop all jobs and reset cloud node state around each test.""" | |
| # Pre-test gate: make sure slurmctld is responsive and the cloud node is | |
| # powered down before the test issues its first RPC, so a slow (or failed) | |
| # previous-test teardown restart cannot race this test's submit/queries. | |
| assert atf.repeat_until( | |
| atf.is_slurmctld_running, | |
| lambda up: up, | |
| timeout=60, | |
| ), "slurmctld did not become responsive before the test" | |
| atf.wait_for_node_state(cloud_node, "POWERED_DOWN", timeout=60, fatal=False) | |
| yield | |
| atf.cancel_all_jobs() | |
| atf.stop_slurmd() | |
| atf.run_command(f"rm -f {_count_only_marker()}", quiet=True) | |
| # Not fatal: a failed write must not skip the clean restart below, or the | |
| # controller keeps the devices this test registered and the next test's | |
| # jobs are bound at scheduling instead of at registration. | |
| _write_gres_conf("", fatal=False) | |
| _write_fake_gpus_conf("", fatal=False) | |
| atf.restart_slurmctld(clean=True) | |
| # Wait for the cloud node to settle back to POWERED_DOWN instead of a blind | |
| # sleep, so the next test starts from a known state on both fast and slow | |
| # hosts. | |
| atf.wait_for_node_state(cloud_node, "POWERED_DOWN", timeout=60, fatal=False) | |
| def _job_gres_line(job_id): | |
| output = atf.run_command_output(f"scontrol show job -d {job_id}", fatal=True) | |
| match = re.search(r" GRES=([^\s]+)", output) | |
| return match.group(1) if match else "" | |
| def _wait_for_node_power_up_complete(): | |
| atf.wait_for_node_state( | |
| cloud_node, | |
| "POWERING_UP", | |
| reverse=True, | |
| timeout=resume_timeout + 5, | |
| fatal=True, | |
| ) | |
| def _wait_for_cloud_power_up(): | |
| atf.wait_for_node_state(cloud_node, "ALLOCATED", timeout=30, fatal=True) | |
| state = atf.get_node_parameter(cloud_node, "state") or "" | |
| if "POWERING_UP" not in state: | |
| # Observing POWERING_UP is best-effort: on a fast power-up the flag may | |
| # already be cleared, so do not hard-fail if it never reappears -- just | |
| # proceed to wait for power-up completion. | |
| atf.wait_for_node_state(cloud_node, "POWERING_UP", timeout=45, fatal=False) | |
| _wait_for_node_power_up_complete() | |
| def _wait_for_cloud_power_up_after_slurmctld_restart(): | |
| """Resume may already be in progress when state is restored after restart.""" | |
| state = atf.get_node_parameter(cloud_node, "state") or "" | |
| if "POWERED_DOWN" in state: | |
| _wait_for_cloud_power_up() | |
| else: | |
| _wait_for_node_power_up_complete() | |
| def _slurmctld_log_size(): | |
| """Current byte size of the slurmctld log, used as a search marker.""" | |
| log_file = atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True) | |
| output = atf.run_command_output(f"wc -c < {log_file}", user="root", quiet=True) | |
| return int(output.strip()) if output and output.strip() else 0 | |
| def _slurmctld_log_text(since=0): | |
| log_file = atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True) | |
| # If the log was rotated/truncated below the mark, the byte offset is stale | |
| # and would over-skip; fall back to reading the whole file in that case. | |
| if since > 0 and _slurmctld_log_size() >= since: | |
| cmd = f"tail -c +{since + 1} {log_file}" | |
| else: | |
| cmd = f"cat {log_file}" | |
| return atf.run_command_output(cmd, user="root", quiet=True) or "" | |
| def _slurmd_log_text(): | |
| log_file = atf.get_config_parameter("SlurmdLogFile", live=False, quiet=True) | |
| log_file = log_file.replace("%n", cloud_node) | |
| return atf.run_command_output(f"cat {log_file}", user="root", quiet=True) or "" | |
| def _assert_count_only_before_registration(job_id, gres_type=None): | |
| """The job holds a count-only GRES reservation until slurmd registers. | |
| Observing the count-only state is inherently racy: ResumeProgram may have | |
| already started slurmd and the controller may have already bound the | |
| allocation by the time this runs. Accept an already-bound (IDX:) allocation | |
| rather than fail -- _assert_gres_bound() still proves the bind happened. | |
| What must never appear here is neither form. | |
| """ | |
| gres_line = _job_gres_line(job_id) | |
| assert "(CNT:" in gres_line or "(IDX:" in gres_line, ( | |
| "expected a count-only (CNT:) or already-bound (IDX:) GRES allocation " | |
| f"while the cloud node powers up, got {gres_line!r}" | |
| ) | |
| if gres_type is not None: | |
| assert ( | |
| gres_type in gres_line | |
| ), f"expected type {gres_type} in the GRES allocation, got {gres_line!r}" | |
| if "(CNT:" not in gres_line: | |
| logging.info( | |
| f"Cloud node registered before the count-only GRES of job {job_id} " | |
| f"could be observed; GRES is already {gres_line!r}" | |
| ) | |
| def _assert_bound_in_place_logged(job_id, since=0): | |
| """slurmctld logged the in-place bind of this job's GRES. | |
| Search only the log written after `since` so a clean restart (which resets | |
| the JobId counter while the shared log keeps growing) cannot match a line | |
| left by an earlier test that reused the same JobId. Poll rather than read | |
| once, since leaving POWERING_UP and flushing the log line are not ordered. | |
| """ | |
| bound_pat = rf"Bound JobId={job_id} GRES in place on node {cloud_node}" | |
| assert atf.repeat_until( | |
| lambda: _slurmctld_log_text(since), | |
| lambda text: re.search(bound_pat, text) is not None, | |
| timeout=30, | |
| ), ( | |
| "Job GRES should be bound in place after cloud node topology " | |
| f"registration; slurmctld log excerpt:\n{_slurmctld_log_text(since)[-4000:]}" | |
| ) | |
| def _assert_gres_bound(job_id, since=0): | |
| # With in-place binding the job never leaves CONFIGURING: at registration | |
| # the count-only reservation is bound to concrete devices and the job | |
| # proceeds straight to RUNNING (it is never de-allocated back to PENDING). | |
| # The in-place path is confirmed by the "Bound ... in place" log plus the | |
| # per-device IDX allocation; a bounce/de-allocation would not emit that log | |
| # and would not yield an IDX allocation. | |
| _assert_bound_in_place_logged(job_id, since) | |
| atf.wait_for_job_state(job_id, "RUNNING", timeout=90, fatal=True) | |
| gres_line = _job_gres_line(job_id) | |
| assert ( | |
| "(IDX:" in gres_line | |
| ), f"expected per-device GRES after in-place bind, got {gres_line!r}" | |
| assert ( | |
| "(CNT:" not in gres_line | |
| ), f"count-only GRES should not remain after bind, got {gres_line!r}" | |
| def _bound_gpu_indices(job_id): | |
| """The device indices in the job's bound GRES, e.g. {0, 1} for IDX:0-1.""" | |
| match = re.search(r"\(IDX:([^)]*)\)", _job_gres_line(job_id)) | |
| assert match, f"expected per-device GRES, got {_job_gres_line(job_id)!r}" | |
| indices = set() | |
| for part in match.group(1).split(","): | |
| first, _, last = part.partition("-") | |
| indices.update(range(int(first), int(last or first) + 1)) | |
| return indices | |
| def _assert_node_healthy_after_gres_registration(): | |
| """Node must not be drained or down after GRES topology registration.""" | |
| node_show = atf.run_command_output(f"scontrol show node {cloud_node}", fatal=True) | |
| state = atf.get_node_parameter(cloud_node, "state") or "" | |
| assert ( | |
| "DOWN" not in state | |
| ), f"cloud node should not be DOWN after registration: {node_show}" | |
| assert ( | |
| "DRAIN" not in state | |
| ), f"cloud node should not be DRAIN after registration: {node_show}" | |
| assert "count changed and jobs are using them" not in node_show, ( | |
| "node Reason should not report GRES count changed while jobs are " "allocated" | |
| ) | |
| gres = atf.get_node_parameter(cloud_node, "gres") or "" | |
| assert re.search( | |
| r"gpu(:|\b)", gres, re.I | |
| ), f"expected gpu in node Gres after registration, got {gres!r}" | |
| def _assert_step_device_constraint(job_id, gres_type=None): | |
| """ | |
| Verify ConstrainDevices actually enforces the bound GPUs on a job step. | |
| Each fake GPU node is major 1 with an unassigned minor, so opening it yields | |
| a cgroup-dependent errno: a device the step is *allowed* returns ENXIO ("No | |
| such device or address"); a device the step is *denied* returns EPERM | |
| ("Operation not permitted"). A step with gpus_requested GPUs must therefore | |
| see exactly gpus_requested devices report ENXIO ("A") and the rest report | |
| EPERM ("D"). | |
| """ | |
| dev_paths = " ".join(f'"{_gpu_dev_base()}{i}"' for i in range(gpu_count)) | |
| check_script = f"{atf.module_tmp_path}/check_gres_devices.sh" | |
| atf.make_bash_script( | |
| check_script, | |
| f""" | |
| echo "CUDA_VISIBLE_DEVICES=${{CUDA_VISIBLE_DEVICES}}" | |
| access="" | |
| for f in {dev_paths}; do | |
| err=$(cat "$f" 2>&1 >/dev/null) | |
| if [[ $? -eq 0 ]]; then | |
| access="${{access}}R" # readable (unconstrained) | |
| elif [[ "$err" == *"Operation not permitted"* ]]; then | |
| access="${{access}}D" # cgroup denied (EPERM) | |
| elif [[ "$err" == *"No such device or address"* ]]; then | |
| access="${{access}}A" # cgroup allowed (ENXIO) | |
| else | |
| access="${{access}}?" | |
| fi | |
| done | |
| echo "Dev_Access: ${{access}}" | |
| """, | |
| ) | |
| gres_spec = ( | |
| f"gpu:{gres_type}:{gpus_requested}" if gres_type else f"gpu:{gpus_requested}" | |
| ) | |
| # --overlap lets this verification step share the CPUs, memory and GRES that | |
| # the job's own step may already hold; without it step creation would block | |
| # until the job ended and time out. --overlap has been available since 20.11, | |
| # well below the versions this test is gated to. | |
| step_out = atf.run_command_output( | |
| f"srun --overlap --jobid={job_id} --gres={gres_spec} {check_script}", | |
| fatal=True, | |
| ) | |
| cuda_match = re.search(r"CUDA_VISIBLE_DEVICES=(.*)", step_out) | |
| assert cuda_match, f"step output missing CUDA_VISIBLE_DEVICES: {step_out!r}" | |
| cuda_indices = cuda_match.group(1).strip().split(",") | |
| assert len(cuda_indices) == gpus_requested, ( | |
| f"expected {gpus_requested} GPU indices in CUDA_VISIBLE_DEVICES, " | |
| f"got {cuda_indices!r}; output={step_out!r}" | |
| ) | |
| access_match = re.search(r"Dev_Access:\s*([RDA?]+)", step_out) | |
| assert access_match, f"step output missing Dev_Access: {step_out!r}" | |
| access = access_match.group(1) | |
| assert ( | |
| len(access) == gpu_count | |
| ), f"expected {gpu_count} device checks, got {access!r}; output={step_out!r}" | |
| assert "?" not in access, ( | |
| f"unexpected device open errno (not EPERM/ENXIO) in pattern {access!r}; " | |
| f"output={step_out!r}" | |
| ) | |
| allowed = access.count("A") | |
| denied = access.count("D") | |
| # These fake GPU nodes are driverless (major 1, unassigned minor), so a | |
| # device the cgroup *allows* opens through to the driver and fails with | |
| # ENXIO ("A"), never exit 0 -- an enforcing cgroup denies the rest with | |
| # EPERM ("D"). Since the tests always request fewer GPUs than the node has, | |
| # an enforcing cgroup denies at least one device; zero denials therefore | |
| # means the device cgroup BPF is not restricting access here (e.g. slurmd | |
| # in a userns without a BPF token, common in SQA Docker). Nothing to | |
| # enforce; skip. | |
| if denied == 0: | |
| slurmd_log = _slurmd_log_text() | |
| if re.search(r"Skipping .*device constrain", slurmd_log): | |
| pytest.skip( | |
| "ConstrainDevices is configured but inactive here: slurmd runs " | |
| "in a user namespace without a cgroup BPF token (common in SQA " | |
| "Docker). Run on bare metal or a privileged host to enforce this." | |
| ) | |
| pytest.fail( | |
| "ConstrainDevices is active but no GPU device was denied to the " | |
| f"step; pattern {access!r}; output={step_out!r}" | |
| ) | |
| assert allowed == gpus_requested, ( | |
| f"cgroup should allow exactly {gpus_requested} bound GPU device(s) " | |
| f"(ENXIO), got pattern {access!r} (A=allowed/ENXIO, D=denied/EPERM, " | |
| f"R=unconstrained); output={step_out!r}" | |
| ) | |
| assert allowed + denied == gpu_count, ( | |
| f"every GPU device should be either cgroup-allowed or -denied, got " | |
| f"{access!r}; output={step_out!r}" | |
| ) | |
| def test_cloud_resume_sanity(): | |
| """ResumeProgram starts slurmd and a non-GRES job completes.""" | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| job_id = atf.submit_job_sbatch( | |
| "-p cloud1 --wrap 'srun hostname'", | |
| fatal=True, | |
| ) | |
| _wait_for_cloud_power_up() | |
| atf.wait_for_job_state( | |
| job_id, "COMPLETED", timeout=atf.PERIODIC_TIMEOUT + 15, fatal=True | |
| ) | |
| def test_cloud_first_registration_gres_bind_sbatch(): | |
| """Batch job binds GRES in place after register and gets indexed GRES.""" | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| # Mark the log before submitting: ResumeProgram may start slurmd, and the | |
| # controller may emit the bind message, before the checks below complete. | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_sbatch( | |
| f"-p cloud1 --gres=gpu:{gpus_requested} -t 300 " "--wrap='/bin/sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id) | |
| _wait_for_cloud_power_up() | |
| _assert_gres_bound(job_id, log_mark) | |
| _assert_node_healthy_after_gres_registration() | |
| def test_cloud_first_registration_gres_bind_typed(): | |
| """A typed GRES request (gpu:<type>:N) also binds in place at registration.""" | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_sbatch( | |
| f"-p cloud1 --gres=gpu:{gpu_type}:{gpus_requested} -t 300 " | |
| "--wrap='/bin/sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id, gres_type=gpu_type) | |
| _wait_for_cloud_power_up() | |
| _assert_gres_bound(job_id, log_mark) | |
| assert gpu_type in _job_gres_line( | |
| job_id | |
| ), f"expected type {gpu_type} in bound GRES, got {_job_gres_line(job_id)!r}" | |
| _assert_node_healthy_after_gres_registration() | |
| def test_cloud_first_registration_gres_bind_srun(): | |
| """Interactive allocation binds GRES in place after register and gets indexed GRES.""" | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_srun( | |
| f"-p cloud1 --gres=gpu:{gpus_requested} -t 300 /bin/sleep infinity", | |
| background=True, | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id) | |
| _wait_for_cloud_power_up() | |
| _assert_gres_bound(job_id, log_mark) | |
| _assert_node_healthy_after_gres_registration() | |
| def test_cloud_gres_bind_after_slurmctld_restart(): | |
| """CONFIGURING count-only job binds in place after slurmctld restart.""" | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_sbatch( | |
| f"-p cloud1 --gres=gpu:{gpus_requested} -t 300 --wrap='/bin/sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id) | |
| atf.restart_slurmctld(clean=False) | |
| _wait_for_cloud_power_up_after_slurmctld_restart() | |
| _assert_gres_bound(job_id, log_mark) | |
| _assert_node_healthy_after_gres_registration() | |
| def test_cloud_registration_with_zero_gpus_drains(): | |
| """Terminal path: a cloud node that powers up without usable GPU device | |
| topology cannot have its count-only allocation bound, so the controller | |
| drains the node and requeues the job instead of leaving it stuck | |
| CONFIGURING. | |
| A gres.conf that lists the GPU count with no device File (and no AutoDetect) | |
| makes slurmd register zero usable GPUs, so _node_config_validate() rejects | |
| the registration on the pre-existing "count reported lower than configured" | |
| check and drains the node before any deferred bind is attempted. The job is | |
| then requeued to PENDING rather than left CONFIGURING forever. | |
| NOTE: this exercises the GRES count-validation terminal path, NOT the | |
| bind-failure path in node_mgr_bind_jobs_on_gres_ready(). That one needs a | |
| node that registers the configured count but still cannot be bound, which | |
| this fixture cannot produce for gpu: a File-less gpu record registers as | |
| zero, not as a count-only gpu. | |
| """ | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| # A drain left over from an earlier test would satisfy the wait below | |
| # without this registration ever happening, so require a clean node first. | |
| start_state = atf.get_node_parameter(cloud_node, "state") or "" | |
| assert ( | |
| "DRAIN" not in start_state | |
| ), f"cloud node {cloud_node} was already drained at test start: {start_state!r}" | |
| # Make ResumeProgram register a GPU count with no device File topology. | |
| atf.run_command(f"touch {_count_only_marker()}", fatal=True, quiet=True) | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_sbatch( | |
| f"-p cloud1 --gres=gpu:{gpu_type}:{gpus_requested} -t 300 " | |
| "--wrap='/bin/sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id, gres_type=gpu_type) | |
| # The node powers up and registers no usable GPUs, so the controller | |
| # rejects the registration and drains the node. | |
| atf.wait_for_node_state( | |
| cloud_node, "DRAIN", timeout=resume_timeout + 60, fatal=True | |
| ) | |
| reason = atf.get_node_parameter(cloud_node, "reason") or "" | |
| assert re.search( | |
| r"gres/gpu count reported lower than configured \(0 < \d+\)", reason | |
| ), ( | |
| "expected the GRES count-validation drain reason set by " | |
| f"_node_config_validate(), got {reason!r}" | |
| ) | |
| # Pin down that the drain really came from this registration rather than | |
| # from leftover state: the node must have been healthy when the test began, | |
| # and slurmctld must log the rejection after the job was submitted. | |
| assert atf.repeat_until( | |
| lambda: _slurmctld_log_text(log_mark), | |
| lambda text: "count reported lower than configured" in text, | |
| timeout=30, | |
| ), ( | |
| "slurmctld should log the GRES count rejection for this registration; " | |
| f"log excerpt:\n{_slurmctld_log_text(log_mark)[-4000:]}" | |
| ) | |
| # Because the node was powering up, the job is requeued rather than killed | |
| # (requeue_on_resume_failure), so it returns to PENDING instead of hanging | |
| # in CONFIGURING. | |
| atf.wait_for_job_state(job_id, "PENDING", timeout=60, fatal=True) | |
| def test_cloud_first_registration_gpus_flag_bind_srun(): | |
| """Job-level --gpus (not --gres=gpu:N) also binds count-only GRES in place on | |
| first cloud registration, and steps can allocate the bound GPUs. | |
| Regression: --gpus / --gpus-per-* leave gres_per_node == 0 and carry the | |
| count in gres_cnt_node_select, so the count-only allocation must take its | |
| per-node count from the derived count rather than gres_per_node. Otherwise | |
| the cloud node registers a 0-count typed GRES that is never recognized as | |
| count-only (so it never binds) and steps fail to allocate it with | |
| "Invalid generic resource (gres) specification" (ESLURM_INVALID_GRES). | |
| """ | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| log_mark = _slurmctld_log_size() | |
| # The srun's own step is created right after the deferred in-place bind, so | |
| # a failure to bind would fail step creation and the job would never run. | |
| job_id = atf.submit_job_srun( | |
| f"-p cloud1 --gpus={gpus_requested} -t 300 /bin/sleep infinity", | |
| background=True, | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id) | |
| _wait_for_cloud_power_up() | |
| _assert_gres_bound(job_id, log_mark) | |
| _assert_node_healthy_after_gres_registration() | |
| # An srun job is RUNNING once its allocation is granted, so that alone does | |
| # not show its steps can use the bound GPUs. Create one and check. | |
| result = atf.run_command( | |
| f"srun --overlap --jobid={job_id} --gpus={gpus_requested} true" | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| f"a step should allocate the {gpus_requested} bound GPUs, got " | |
| f"rc={result['exit_code']}: {result['stderr']!r}" | |
| ) | |
| def test_cloud_first_registration_binds_two_jobs(): | |
| """Two count-only jobs on one cloud node bind to different GPUs.""" | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| log_mark = _slurmctld_log_size() | |
| # node1 has one CPU, so the second job shares it (--oversubscribe). | |
| # GRES is never shared, so each job still needs GPUs of its own. | |
| job_ids = [ | |
| atf.submit_job_sbatch( | |
| f"-p cloud1 --oversubscribe --gres=gpu:{gpus_requested} -t 300 " | |
| "--wrap='sleep infinity'", | |
| fatal=True, | |
| ) | |
| for _ in range(2) | |
| ] | |
| for job_id in job_ids: | |
| atf.wait_for_job_state(job_id, "CONFIGURING", fatal=True) | |
| _assert_count_only_before_registration(job_id) | |
| # Two jobs share the node, so it reports MIXED, never ALLOCATED. | |
| _wait_for_node_power_up_complete() | |
| for job_id in job_ids: | |
| _assert_gres_bound(job_id, log_mark) | |
| first, second = (_bound_gpu_indices(job_id) for job_id in job_ids) | |
| assert ( | |
| len(first) == gpus_requested and len(second) == gpus_requested | |
| ), f"each job should bind {gpus_requested} GPUs, got {first} and {second}" | |
| assert not first & second, f"the jobs were bound to the same GPUs: {first & second}" | |
| _assert_node_healthy_after_gres_registration() | |
| @pytest.mark.parametrize( | |
| "gres_spec, expect_gpu_bind", | |
| [ | |
| (f"{fileless_gres}:{fileless_requested}", False), | |
| (f"gpu:{gpus_requested},{fileless_gres}:{fileless_requested}", True), | |
| ], | |
| ids=["fileless_only", "gpu_and_fileless"], | |
| ) | |
| def test_cloud_registration_fileless_gres_not_drained(gres_spec, expect_gpu_bind): | |
| """A GRES with no device File is left count-only, not failed, at registration. | |
| A GRES with no File topology anywhere (here bandwidth, declared only in | |
| slurm.conf) is count-only by design: there are no devices to bind it to. | |
| First cloud registration must launch such a job as it is, rather than read | |
| its count-only allocation as a failed bind, drain the node and fail the job. | |
| When the job also requests GPUs, the GPUs still bind in place and the | |
| fileless GRES stays count-only. | |
| """ | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_sbatch( | |
| f"-p cloud1 --gres={gres_spec} -t 300 --wrap='sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", fatal=True) | |
| _wait_for_cloud_power_up() | |
| # Treating the fileless GRES as a failed bind drains the node and fails the | |
| # job back to PENDING, so it would never reach RUNNING. | |
| atf.wait_for_job_state(job_id, "RUNNING", fatal=True) | |
| _assert_node_healthy_after_gres_registration() | |
| log_text = _slurmctld_log_text(log_mark) | |
| assert "could not bind GRES" not in log_text, ( | |
| "a fileless GRES must not be treated as a failed bind; slurmctld log " | |
| f"excerpt:\n{log_text[-4000:]}" | |
| ) | |
| gres_line = _job_gres_line(job_id) | |
| assert re.search( | |
| rf"\b{fileless_gres}\(CNT:{fileless_requested}\)", gres_line | |
| ), f"expected the fileless GRES to stay count-only, got {gres_line!r}" | |
| if expect_gpu_bind: | |
| _assert_bound_in_place_logged(job_id, log_mark) | |
| assert re.search( | |
| r"\bgpu[^,]*\(IDX:", gres_line | |
| ), f"expected per-device GPU GRES after in-place bind, got {gres_line!r}" | |
| @pytest.mark.parametrize("gres_type", [None, gpu_type], ids=["untyped", "typed"]) | |
| def test_cloud_bound_gres_device_constraint(device_cgroup_testable, gres_type): | |
| """ConstrainDevices enforces exactly the GPUs bound in place at registration. | |
| Deliberately kept separate from the bind tests above: whether the device | |
| cgroup actually enforces anything depends on the host (slurmd in a user | |
| namespace without a BPF token cannot program the device BPF map), so this | |
| check may legitimately skip. Folding it into the bind tests would mark those | |
| SKIPPED too and silently erase their count-only-to-bound coverage. | |
| """ | |
| assert "POWERED_DOWN" in atf.get_node_parameter( | |
| cloud_node, "state" | |
| ), f"cloud node {cloud_node} was not POWERED_DOWN at test start: {atf.get_node_parameter(cloud_node, 'state')!r}" | |
| gres_spec = ( | |
| f"gpu:{gres_type}:{gpus_requested}" if gres_type else f"gpu:{gpus_requested}" | |
| ) | |
| log_mark = _slurmctld_log_size() | |
| job_id = atf.submit_job_sbatch( | |
| f"-p cloud1 --gres={gres_spec} -t 300 --wrap='/bin/sleep infinity'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "CONFIGURING", timeout=45, fatal=True) | |
| _assert_count_only_before_registration(job_id, gres_type=gres_type) | |
| _wait_for_cloud_power_up() | |
| _assert_gres_bound(job_id, log_mark) | |
| _assert_node_healthy_after_gres_registration() | |
| _assert_step_device_constraint(job_id, gres_type=gres_type) |