| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Verify RPC listeners reject non-TLS connections when TLS is required. | |
| Only runs on a TLS-enabled cluster (the -s2n variant). Confirms the | |
| fail-closed behavior from issue 51066: a client connecting without TLS is | |
| rejected with ESLURM_TLS_REQUIRED before its RPC is parsed or dispatched, | |
| rather than being served in cleartext. That the reject really does precede | |
| authorization is pinned separately against slurmd. | |
| The non-TLS client is made with a SLURM_CONF override that includes the real | |
| slurm.conf but sets TLSType=tls/none, pointed at only the client or task | |
| process; control cases use the real config. Each reject case is paired with | |
| a control that runs the same probe against the same listener and differs | |
| only in SLURM_CONF, so a reject is shown to be specific to the missing TLS | |
| rather than a dead listener. | |
| Listeners covered: | |
| * slurmctld and slurmd, which reject via the http_switch path; | |
| * srun's step-launch listener, which rejects via | |
| on_rpc_connection_data() (the client-listener fix), probed from a task | |
| inside the step. sattach opens the same listener, but only srun names | |
| its RPC port to tasks (SLURM_SRUN_COMM_PORT) while sattach's is | |
| indistinguishable from its I/O ports, so srun stands in for both; | |
| * salloc's allocation-message listener, which rejects via the same path, | |
| probed at the ephemeral port read from the process itself. This one is | |
| reject-only: an outside client has no certificate to complete mutual | |
| TLS with it, so no control is possible (see the test for why that is | |
| still conclusive). | |
| swait's listener is covered by test_166_3, which needs a stepmgr cluster. | |
| scrun's is not covered anywhere: it is the only listener whose behavior | |
| this change really alters, since it previously requested no TLS | |
| fingerprinting at all, but it needs an OCI runtime that is not built in the | |
| environments this suite runs in. It is the first listener to add if that | |
| changes. | |
| The reject paths landed in different releases, so each test gates on the | |
| component owning its path rather than a cluster-wide version. | |
| What is rejected is a non-TLS *RPC*, not everything on the port. | |
| slurmctld.8 and slurmd.8 document HTTP as served on the same port unless | |
| CommunicationParameters=disable_http, with TLS optional, so a plaintext | |
| HTTP client is still answered; that boundary is pinned here too. | |
| """ | |
| import os | |
| import re | |
| import pytest | |
| import requests | |
| import atf | |
| # Reasons for the per-component version gates below. | |
| CTLD_REASON = "Issue 51066: slurmctld TLS-required reply added in 25.11" | |
| SLURMD_REASON = "Issue 51066: slurmd TLS-required reply added in 25.11" | |
| SRUN_REASON = "Issue 51066: conmgr client-listener reject added in 26.05.4" | |
| # salloc only moved its allocation-message listener onto conmgr in 26.11, so | |
| # an older salloc has no reject to exercise. Marked xfail rather than skipped | |
| # so an older salloc that does reject is reported (xfail_strict) instead of | |
| # silently passing this gate off as correct. | |
| ALLOCATE_MSG_REASON = ( | |
| "Issue 51066: salloc allocation-message listener moved to conmgr in 26.11" | |
| ) | |
| # slurm_errno.h, explicitly valued and unchanged since it was added in 25.11. | |
| ESLURM_TLS_REQUIRED = 13000 | |
| # slurm_errno.h. What slurmd answers an unprivileged from_slurmctld RPC. | |
| ESLURM_USER_ID_MISSING = 2010 | |
| # root and SlurmUser pass slurmd's authorization check, so a ping from them is | |
| # answered rather than refused and cannot show where the check sits. | |
| PRIVILEGED_TEST_USER = ( | |
| atf.properties["test-user-uid"] == 0 | |
| or atf.properties["test-user"] == atf.properties["slurm-user"] | |
| ) | |
| PRIVILEGED_REASON = ( | |
| "Issue 51066: needs a test user slurmd refuses, but the suite runs as" | |
| " root or SlurmUser" | |
| ) | |
| # slurm_strerror(ESLURM_TLS_REQUIRED), quoted as the admin-facing symptom in | |
| # the Enforcement section of doc/html/tls.shtml. | |
| TLS_REQUIRED_MSG = "TLS missing but required for connection" | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| atf.require_tls() | |
| atf.require_tool("gcc") | |
| # With CommunicationParameters=disable_http the listener is a plain RPC | |
| # listener, so the http_switch reply the slurmctld legs assert is never | |
| # reached and the reject comes from the conmgr fix instead. A slurmctld | |
| # satisfying CTLD_REASON but predating that fix accepts the plaintext | |
| # probe. test_166_2 covers that configuration. | |
| atf.require_config_parameter_excludes("CommunicationParameters", "disable_http") | |
| atf.require_slurm_running() | |
| @pytest.fixture(scope="module") | |
| def non_tls_conf(module_setup): | |
| """Path to a slurm.conf that includes the real config but disables TLS. | |
| Only the process pointed at this via SLURM_CONF speaks plaintext; srun and | |
| the daemons keep the real (TLS) configuration. | |
| Returned absolute: the tests consuming this run in their own per-function | |
| directory, and the srun legs read it from inside a job step. | |
| """ | |
| real_conf = f"{atf.properties['slurm-config-dir']}/slurm.conf" | |
| path = "non_tls_slurm.conf" | |
| atf.run_command( | |
| f"printf 'include {real_conf}\\nTLSType=tls/none\\n' > {path}", | |
| fatal=True, | |
| ) | |
| return os.path.abspath(path) | |
| @pytest.fixture(scope="module") | |
| def slurmctld_target(): | |
| """The "<host> <port>" probe argument for the slurmctld RPC listener. | |
| SlurmctldPort is reported as "first-last" when SlurmctldPortCount > 1, so | |
| take the first port rather than handing the range to the probe. | |
| """ | |
| port = str(atf.properties["slurmctld_port"]).split("-")[0] | |
| return f"{atf.properties['slurmctld_host']} {port}" | |
| @pytest.fixture(scope="module") | |
| def slurmd_target(): | |
| """The "<host> <port>" probe argument for a slurmd RPC listener. | |
| Read per node rather than from SlurmdPort, since a NodeName line may | |
| override the port and the suite's clusters commonly do. These are the | |
| live "scontrol show nodes --json" field names. | |
| """ | |
| node = sorted(atf.get_nodes())[0] | |
| host = atf.get_node_parameter(node, "address") or node | |
| port = atf.get_node_parameter(node, "port") | |
| return f"{host} {port}" | |
| @pytest.fixture(scope="module") | |
| def probe(module_setup): | |
| """Compile the non-TLS probe helper and return its path. | |
| Internal RPC helpers live in libslurmfull; internal headers need the source | |
| tree on the include path. | |
| Returned absolute for the same reason as non_tls_conf, and because the | |
| srun legs exec it as a task. | |
| """ | |
| source = f"{atf.properties['testsuite_scripts_dir']}/tls_reject_probe.c" | |
| dest = "tls_reject_probe" | |
| atf.compile_against_libslurm(source, dest, full=True, fatal=True) | |
| return os.path.abspath(dest) | |
| @pytest.fixture(scope="module") | |
| def pmi_probe(module_setup): | |
| """Compile a minimal libpmi client and return its absolute path. | |
| libpmi lives in the install lib dir rather than next to libslurmfull, and | |
| that dir is lib64 on distributions that split it. | |
| """ | |
| prefix = atf.properties["slurm-prefix"] | |
| lib_dir = "lib64" if os.path.isfile(f"{prefix}/lib64/libpmi.so") else "lib" | |
| source = f"{atf.properties['testsuite_scripts_dir']}/pmi_tls_probe.c" | |
| dest = "pmi_tls_probe" | |
| atf.compile_against_libslurm( | |
| source, | |
| dest, | |
| build_args=f"-Wl,-rpath={prefix}/{lib_dir} -L{prefix}/{lib_dir} -lpmi", | |
| full=True, | |
| fatal=True, | |
| ) | |
| return os.path.abspath(dest) | |
| # --------------------------------------------------------------------------- # | |
| # slurmctld listener (http_switch reject path) | |
| # --------------------------------------------------------------------------- # | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON) | |
| def test_slurmctld_rejects_non_tls(slurmctld_target, non_tls_conf, probe): | |
| """A non-TLS client is rejected by slurmctld's RPC listener. | |
| The probe compares the numeric return code against ESLURM_TLS_REQUIRED, | |
| so this does not depend on the wording of the error message. | |
| """ | |
| result = atf.run_command( | |
| f"{probe} {slurmctld_target}", | |
| env_vars=f"SLURM_CONF={non_tls_conf}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in output | |
| ), f"Expected slurmctld to reject the non-TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON) | |
| def test_slurmctld_accepts_tls(slurmctld_target, probe): | |
| """Control: the same probe under the real (TLS) config is accepted. | |
| Differs from the reject case only in SLURM_CONF, so an ACCEPTED result | |
| shows the reject is caused by the missing TLS and not by the probe. | |
| """ | |
| result = atf.run_command( | |
| f"{probe} {slurmctld_target}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=ACCEPTED" in output | |
| ), f"Expected slurmctld to accept the valid TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON) | |
| def test_sinfo_rejected_without_tls(non_tls_conf): | |
| """A documented client command cannot reach slurmctld without TLS. | |
| The probe legs assert the exact return code over an internal API; this | |
| asserts the same contract as a user meets it. sinfo reports the errno it | |
| got, so it pins the message tls.shtml tells admins to look up; scontrol | |
| ping only reports the controller as DOWN and would pass on any failure. | |
| """ | |
| result = atf.run_command("sinfo", env_vars=f"SLURM_CONF={non_tls_conf}", xfail=True) | |
| assert ( | |
| TLS_REQUIRED_MSG in result["stderr"] | |
| ), f"Expected sinfo to report the TLS-required error, got:\n{result}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON) | |
| def test_sinfo_accepted_with_tls(): | |
| """Control: the same command under the real (TLS) config succeeds. | |
| Distinguishes "rejected for want of TLS" from "controller is down". | |
| """ | |
| result = atf.run_command("sinfo") | |
| assert ( | |
| result["exit_code"] == 0 | |
| ), f"Expected sinfo to succeed with TLS, got:\n{result}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON) | |
| def test_plaintext_http_still_served(): | |
| """Requiring TLS for RPCs does not close plaintext HTTP on the same port. | |
| slurmctld.8 promises HTTP on SlurmctldPort with TLS wrapping optional and | |
| without requiring TLSType, so this pins the documented exception to the | |
| rejection asserted above. atf.request_slurmctld() speaks plain http://. | |
| Requests the endpoint index rather than a liveness probe: slurmctld.8 | |
| documents "GET /" as returning the list of endpoints, so asserting on the | |
| body shows the port still serves response content in cleartext, not just | |
| that something answers the socket. That is what tls.shtml means when it | |
| tells admins to set disable_http if the port must not carry cleartext. | |
| The endpoints that return cluster data would demonstrate it more sharply, | |
| but /metrics* needs MetricsType configured and /{data_parser}/conf and | |
| readyz?verbose need a token, neither of which this module otherwise | |
| requires. | |
| """ | |
| resp = atf.request_slurmctld("") | |
| assert ( | |
| resp.ok | |
| ), f"Expected plaintext HTTP to still be served, got {resp.status_code}" | |
| assert ( | |
| "/healthz" in resp.text | |
| ), f"Expected the endpoint index to be served in cleartext, got:\n{resp.text}" | |
| # --------------------------------------------------------------------------- # | |
| # slurmd listener (http_switch reject path) | |
| # --------------------------------------------------------------------------- # | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmd") < (25, 11), reason=SLURMD_REASON) | |
| def test_slurmd_rejects_non_tls(slurmd_target, non_tls_conf, probe): | |
| """A non-TLS client is rejected by slurmd's RPC listener. | |
| slurmd hands its listening socket to the same http_switch as slurmctld, | |
| so this pins the second daemon tls.shtml names rather than assuming the | |
| two share a code path. | |
| """ | |
| result = atf.run_command( | |
| f"{probe} {slurmd_target}", | |
| env_vars=f"SLURM_CONF={non_tls_conf}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in output | |
| ), f"Expected slurmd to reject the non-TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmd") < (25, 11), reason=SLURMD_REASON) | |
| def test_slurmd_accepts_tls(slurmd_target, probe): | |
| """Control: the same probe under the real (TLS) config reaches slurmd. | |
| slurmd marks REQUEST_PING from_slurmctld, so what a dispatched ping | |
| answers depends on who runs the probe: an unprivileged sender is refused | |
| ESLURM_USER_ID_MISSING at the authorization check, while root or SlurmUser | |
| reaches _rpc_ping() and gets RESPONSE_PING_SLURMD, which | |
| slurm_get_return_code() maps to SLURM_SUCCESS. Accept either; both are | |
| only reachable once the listener has accepted the connection and | |
| dispatched the RPC, which is all this control needs to show. | |
| """ | |
| result = atf.run_command( | |
| f"{probe} {slurmd_target}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=ACCEPTED" in output or f"rc={ESLURM_USER_ID_MISSING}" in output | |
| ), f"Expected slurmd to accept and dispatch the TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmd") < (25, 11), reason=SLURMD_REASON) | |
| def test_slurmd_plaintext_http_still_served(slurmd_target): | |
| """slurmd also keeps serving plaintext HTTP on its RPC port. | |
| slurmd.8 documents the same HTTP server and the same "GET /" endpoint | |
| index as slurmctld.8, so the documented exception is pinned for both | |
| daemons rather than assumed to carry over. | |
| """ | |
| host, port = slurmd_target.split() | |
| resp = requests.get(f"http://{host}:{port}/") | |
| assert ( | |
| resp.ok | |
| ), f"Expected plaintext HTTP to still be served, got {resp.status_code}" | |
| assert ( | |
| "/healthz" in resp.text | |
| ), f"Expected the endpoint index to be served in cleartext, got:\n{resp.text}" | |
| # --------------------------------------------------------------------------- # | |
| # Ordering of the reject relative to authorization | |
| # --------------------------------------------------------------------------- # | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmd") < (25, 11), reason=SLURMD_REASON) | |
| @pytest.mark.skipif(PRIVILEGED_TEST_USER, reason=PRIVILEGED_REASON) | |
| def test_reject_precedes_authorization(slurmd_target, non_tls_conf, probe): | |
| """A rejected connection never reaches the listener's authorization check. | |
| tls.shtml promises the connection is closed "before the RPC is parsed, | |
| authenticated, or dispatched". Every other leg here sends a well-formed, | |
| credentialed RPC that the listener would accept, so a listener that | |
| authorized first and rejected afterwards would look identical to one that | |
| rejects first. | |
| slurmd's REQUEST_PING distinguishes them: it is marked from_slurmctld, so | |
| for an unprivileged sender authorize-then-reject answers | |
| ESLURM_USER_ID_MISSING while reject-then-authorize answers | |
| ESLURM_TLS_REQUIRED. The TLS run establishes that this RPC really is one | |
| slurmd refuses at authorization, which is what makes the non-TLS run's | |
| answer evidence of the ordering. That only holds while the probe runs as | |
| a uid slurmd refuses, hence the skip above. | |
| """ | |
| dispatched = atf.run_command(f"{probe} {slurmd_target}", fatal=True) | |
| rejected = atf.run_command( | |
| f"{probe} {slurmd_target}", | |
| env_vars=f"SLURM_CONF={non_tls_conf}", | |
| fatal=True, | |
| ) | |
| dispatched_output = dispatched["stdout"] + dispatched["stderr"] | |
| rejected_output = rejected["stdout"] + rejected["stderr"] | |
| assert f"rc={ESLURM_USER_ID_MISSING}" in dispatched_output, ( | |
| "Expected slurmd to refuse this RPC at authorization when it is" | |
| f" dispatched, got:\n{dispatched_output}" | |
| ) | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in rejected_output | |
| ), f"Expected the non-TLS connection to be rejected, got:\n{rejected_output}" | |
| assert f"rc={ESLURM_USER_ID_MISSING}" not in rejected_output, ( | |
| "Expected the non-TLS connection to be rejected before authorization," | |
| f" but it reached the authorization check:\n{rejected_output}" | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # srun step-launch listener (on_rpc_connection_data reject path) | |
| # --------------------------------------------------------------------------- # | |
| @pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON) | |
| def test_step_launch_rejects_non_tls(non_tls_conf, probe): | |
| """srun's step-launch listener rejects a non-TLS connection from a task. | |
| Exercises on_rpc_connection_data()'s reject path (the client-listener fix, | |
| shared with the allocate_msg and swait listeners). The probe runs as a task | |
| with the non-TLS config, so it connects back to srun's step-launch listener | |
| (SLURM_SRUN_COMM_HOST/PORT) in plaintext. | |
| """ | |
| result = atf.run_command( | |
| f"srun -N1 -n1 -t1 env SLURM_CONF={non_tls_conf} {probe}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in output | |
| ), f"Expected srun to reject the non-TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON) | |
| def test_step_launch_accepts_tls(probe): | |
| """Control: a valid TLS task is accepted by srun's step-launch listener. | |
| srun's listener presents a per-allocation certificate that is pinned | |
| through the step launch (not CA-signed). The probe reads that cert from | |
| SLURM_SRUN_TLS_CERT (exported by srun for the task) so it can establish | |
| trusted TLS, the same mechanism libpmi uses to talk back to srun. | |
| """ | |
| result = atf.run_command( | |
| f"srun -N1 -n1 -t1 {probe}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=ACCEPTED" in output | |
| ), f"Expected srun to accept the valid TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON) | |
| def test_pmi_task_rejected_without_tls(non_tls_conf, pmi_probe): | |
| """A PMI task pointed at a non-TLS config cannot complete its exchange. | |
| This is the reported reproducer rather than a synthetic RPC: libpmi is a | |
| real client of srun's step launch listener, so it exercises the same | |
| rejection through an interface tasks actually use. | |
| """ | |
| result = atf.run_command( | |
| f"srun -N1 -n1 -t1 env SLURM_CONF={non_tls_conf} {pmi_probe}", | |
| xfail=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PMI_RESULT=OK" not in output | |
| ), f"Expected the PMI exchange to fail without TLS, got:\n{output}" | |
| assert ( | |
| f"error_code={ESLURM_TLS_REQUIRED}" in output | |
| ), f"Expected the PMI failure to be the TLS-required error, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON) | |
| def test_pmi_task_accepted_with_tls(pmi_probe): | |
| """Control: the same PMI task under the real (TLS) config completes.""" | |
| result = atf.run_command( | |
| f"srun -N1 -n1 -t1 {pmi_probe}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PMI_RESULT=OK" in output | |
| ), f"Expected the PMI exchange to succeed with TLS, got:\n{output}" | |
| # --------------------------------------------------------------------------- # | |
| # salloc allocation-message listener (on_rpc_connection_data reject path) | |
| # --------------------------------------------------------------------------- # | |
| # Long enough that salloc is still holding the allocation, with its | |
| # allocation-message listener open, when the probe connects. | |
| SALLOC_HOLD_SECS = 60 | |
| # salloc prints this once the allocation is granted; waited for so the probe | |
| # runs against an established allocation rather than a half-started salloc. | |
| GRANTED_RE = re.compile(r"Granted job allocation (\d+)") | |
| def _allocate_msg_target(): | |
| """Return "<host> <port>" for salloc's listening TCP socket, or None. | |
| salloc's allocation-message listener is the only socket it keeps in LISTEN | |
| while running a command, and its ephemeral port is never reported to a | |
| client, so it is read from salloc's own sockets. lsof matches by command | |
| name, not PID, because atf runs the backgrounded salloc under a bash | |
| wrapper whose PID is the one the caller sees. | |
| """ | |
| listeners = atf.run_command_output( | |
| "lsof -a -c salloc -iTCP -sTCP:LISTEN -P -n -Fn", quiet=True | |
| ) | |
| for line in listeners.splitlines(): | |
| if not line.startswith("n"): | |
| continue | |
| host, _, port = line[1:].rpartition(":") | |
| host = host.strip("[]") | |
| if host in ("*", "", "0.0.0.0", "::"): | |
| host = atf.properties["slurmctld_host"] | |
| return f"{host} {port}" | |
| return None | |
| @pytest.fixture | |
| def salloc_listener(): | |
| """Hold an allocation with salloc and yield its allocate_msg "<host port>". | |
| salloc runs a local command for the life of the allocation, keeping the | |
| allocation-message listener open, and its ephemeral port is read from | |
| salloc's own sockets. salloc is terminated when the test finishes, | |
| including when it fails first. | |
| """ | |
| atf.require_tool("lsof") | |
| log = "salloc.out" | |
| process = atf.run_command( | |
| f"salloc -N1 sleep {SALLOC_HOLD_SECS} > {log} 2>&1", | |
| background=True, | |
| )["process"] | |
| job_id = None | |
| target = None | |
| for _ in atf.timer(fatal=True): | |
| log_output = atf.run_command_output(f"cat {log}", quiet=True) | |
| if process.poll() is not None: | |
| pytest.fail(f"salloc exited before opening its listener:\n{log_output}") | |
| if job_id is None: | |
| granted = GRANTED_RE.search(log_output) | |
| if granted: | |
| job_id = granted[1] | |
| target = _allocate_msg_target() | |
| if job_id and target: | |
| break | |
| yield target | |
| process.terminate() | |
| @pytest.mark.xfail(atf.get_version("bin/salloc") < (26, 11), reason=ALLOCATE_MSG_REASON) | |
| def test_allocate_msg_rejects_non_tls(non_tls_conf, probe, salloc_listener): | |
| """A non-TLS client is rejected by salloc's allocation-message listener. | |
| No valid-TLS control is probed: an outside client has no certificate to | |
| complete mutual TLS with salloc's listener, and salloc sends no reply to a | |
| bare REQUEST_PING even over TLS. TLS_REQUIRED is proof enough on its own, | |
| since only a live listener enforcing TLS returns it; one that was down or | |
| unreachable would report NO_RESPONSE. | |
| """ | |
| result = atf.run_command( | |
| f"{probe} {salloc_listener}", | |
| env_vars=f"SLURM_CONF={non_tls_conf}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in output | |
| ), f"Expected salloc's listener to reject the non-TLS probe, got:\n{output}" |