| ############################################################################ | |
| # Copyright (C) SchedMD LLC. | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| import getpass | |
| import json | |
| import logging | |
| import os | |
| import pwd | |
| import random | |
| import shlex | |
| import time | |
| import pytest | |
| import requests | |
| import yaml | |
| import atf | |
| pytestmark = pytest.mark.slow | |
| random.seed() | |
| cluster_name = f"test-cluster-{random.randrange(0, 99999999999)}" | |
| cluster2_name = f"{cluster_name}-2" | |
| user_name = f"test-user-{random.randrange(0, 99999999999)}" | |
| user_name2 = f"{user_name}-2" | |
| local_user_name = getpass.getuser() | |
| account_name = f"test-account-{random.randrange(0, 99999999999)}" | |
| account2_name = f"{account_name}-2" | |
| account3_name = f"{account_name}-3" | |
| coord_name = f"{user_name}-commander" | |
| wckey_name = f"test-wckey-{random.randrange(0, 99999999999)}" | |
| wckey2_name = f"{wckey_name}-2" | |
| qos_name = f"test-qos-{random.randrange(0, 99999999999)}" | |
| qos2_name = f"{qos_name}-2" | |
| resv_name = f"test-reservation-{random.randrange(0, 99999999999)}" | |
| req_node_count = 10 | |
| topology_names = [ | |
| "tree", | |
| "block", | |
| "flat1", | |
| "flat2", | |
| "flat_shorthand", | |
| "ring", | |
| "torus", | |
| ] | |
| topology_yaml = """ | |
| --- | |
| - topology: tree | |
| cluster_default: false | |
| tree: | |
| switches: | |
| - switch: sw_root | |
| children: s[1-2] | |
| - switch: s1 | |
| nodes: node[1-2] | |
| - switch: s2 | |
| nodes: node[3-4] | |
| - topology: block | |
| cluster_default: false | |
| block: | |
| block_sizes: | |
| - 2 | |
| - 4 | |
| blocks: | |
| - block: b1 | |
| nodes: node[1-2] | |
| - block: b2 | |
| nodes: node[3-4] | |
| - block: b3 | |
| nodes: node[5-6] | |
| - block: b4 | |
| nodes: node[7-8] | |
| - topology: flat1 | |
| cluster_default: false | |
| flat: | |
| alpha_step_rank: false | |
| - topology: flat2 | |
| cluster_default: true | |
| flat: | |
| alpha_step_rank: true | |
| # The documented shorthand form, which carries no config object and so | |
| # exercises the default-config branch of the flat topoinfo dump. | |
| - topology: flat_shorthand | |
| cluster_default: false | |
| flat: true | |
| - topology: ring | |
| cluster_default: false | |
| ring: | |
| rings: | |
| - ring: ring0 | |
| nodes: node[1-4] | |
| - ring: ring1 | |
| nodes: node[5-8] | |
| - topology: torus | |
| cluster_default: false | |
| torus3d: | |
| toruses: | |
| - name: pod1 | |
| dims: | |
| x: 2 | |
| y: 2 | |
| z: 2 | |
| nodes: node[1-8] | |
| placements: | |
| - dims: | |
| x: 1 | |
| y: 1 | |
| z: 1 | |
| - dims: | |
| x: 1 | |
| y: 1 | |
| z: 2 | |
| - dims: | |
| x: 2 | |
| y: 2 | |
| z: 2 | |
| """ | |
| NONEXISTENT_JOB_ID = "999999999" | |
| runtime_hook_marker = "REST_RUNTIME_JOB_DESC_HOOK" | |
| runtime_lua_script = """ | |
| function slurm_runtime_setup(id) return slurm.SUCCESS end | |
| function slurm_runtime_cleanup(id) return slurm.SUCCESS end | |
| function slurm_runtime_task_init(id, task_id) return slurm.SUCCESS end | |
| function slurm_runtime_run(id, task_id) return slurm.SUCCESS end | |
| """ | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| global slurmrestd_url, token, slurmrestd | |
| global local_cluster_name, partition_name | |
| atf.require_version((26, 11), "sbin/slurmdbd") | |
| atf.require_version((26, 11), "sbin/slurmctld") | |
| atf.require_version((26, 11), "sbin/slurmrestd") | |
| atf.require_slurmrestd("slurmctld,slurmdbd,util", "v0.0.46") | |
| atf.require_accounting(modify=True) | |
| atf.require_nodes(req_node_count) | |
| atf.require_config_parameter("AllowNoDefAcct", "Yes", source="slurmdbd") | |
| atf.require_config_parameter("TrackWCKey", "Yes", source="slurmdbd") | |
| atf.require_config_parameter("TrackWCKey", "Yes") | |
| atf.require_config_parameter_includes("AuthAltTypes", "auth/jwt") | |
| atf.require_config_parameter_includes("AuthAltTypes", "auth/jwt", source="slurmdbd") | |
| atf.require_config_file("topology.yaml", topology_yaml) | |
| atf.require_slurm_running() | |
| # Setup OpenAPI client with OpenAPI-Generator once Slurm(restd) is running | |
| atf.require_openapi_generator("7.3.0") | |
| # Uncomment the following two lines to generate a new openapi_spec.json | |
| # file, then it could be moved to the testsuite_data_dir if we really | |
| # want to change the OpenAPI specs after .0. | |
| # with open("openapi_spec.json", "w") as f: | |
| # json.dump(atf.properties["openapi_spec"], f, indent=2) | |
| # Conf reliant variables (put here to avert --auto-config errors) | |
| local_cluster_name = atf.get_config_parameter("ClusterName") | |
| partition_name = atf.default_partition() | |
| if not partition_name: | |
| partition_name = "debug" | |
| @pytest.fixture(scope="function") | |
| def slurm(setup): | |
| yield atf.openapi_slurm() | |
| @pytest.fixture(scope="function") | |
| def slurmdb(setup): | |
| yield atf.openapi_slurmdb() | |
| def _delete_user(delete_args): | |
| """Delete a user once slurmdbd no longer accounts active jobs for it. | |
| Cancelled jobs are done for slurmctld before slurmdbd receives their end | |
| records, and sacctmgr refuses to delete a user with active jobs. | |
| """ | |
| for t in atf.timer(poll_interval=0.2, fatal=True): | |
| result = atf.run_command( | |
| f"sacctmgr -i delete user {delete_args}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| if "Job(s) active" not in result["stderr"]: | |
| break | |
| @pytest.fixture(scope="function") | |
| def admin_level(setup): | |
| atf.run_command( | |
| f"sacctmgr -i add user {local_cluster_name} defaultaccount=root AdminLevel=Admin", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| yield | |
| atf.cancel_all_jobs() | |
| _delete_user(local_cluster_name) | |
| @pytest.fixture(scope="function") | |
| def non_admin(setup): | |
| atf.run_command( | |
| f"sacctmgr -i add user {local_cluster_name} defaultaccount=root", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| _wait_for_assoc_mgr_account(local_cluster_name, "root") | |
| yield | |
| atf.cancel_all_jobs() | |
| _delete_user(local_cluster_name) | |
| @pytest.fixture(scope="function") | |
| def resv_crash(setup): | |
| """Ensure that slurmrestd is running and reservation is deleted""" | |
| resv_name = "crash_resv_name" | |
| yield resv_name | |
| if not atf.is_slurmrestd_running(): | |
| atf.start_slurmrestd() | |
| # We have a new port | |
| atf.properties["openapi_config"].host = atf.properties["slurmrestd_url"] | |
| atf.run_command(f"scontrol delete reservationname={resv_name}", user="root") | |
| @pytest.fixture(scope="function") | |
| def create_accounts(): | |
| atf.run_command( | |
| f"sacctmgr -i create account {account_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i create account {account2_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| yield | |
| atf.run_command( | |
| f"sacctmgr -i delete account {account_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i delete account {account2_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| @pytest.fixture(scope="function") | |
| def create_users(create_accounts): | |
| atf.run_command( | |
| f"sacctmgr -i create user {user_name} cluster={local_cluster_name} account={account_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| yield | |
| atf.cancel_all_jobs() | |
| _delete_user(f"{user_name} cluster={local_cluster_name} account={account_name}") | |
| @pytest.fixture(scope="function") | |
| def association_enforcement(): | |
| """Require AccountingStorageEnforce=associations. | |
| In local-config mode only the tests using this fixture are skipped. | |
| In auto-config mode enforcement is enabled and, like any configuration | |
| change, stays in effect until the module is torn down. | |
| """ | |
| atf.require_config_parameter_includes("AccountingStorageEnforce", "associations") | |
| def _wait_for_assoc_mgr_account(user, account): | |
| """Wait until slurmctld's association cache reflects a sacctmgr change. | |
| sacctmgr modifies slurmdbd directly; slurmctld's in-memory assoc_mgr | |
| cache is updated asynchronously, so job submissions right after a | |
| sacctmgr add/delete can otherwise race a stale cache. | |
| """ | |
| for t in atf.timer(fatal=True): | |
| output = atf.run_command_output( | |
| f"scontrol show assoc_mgr users={user} flags=assoc", fatal=True | |
| ) | |
| if f"Account={account}" in output: | |
| break | |
| def _wait_for_no_assoc_mgr_user(user): | |
| """Wait until slurmctld's association cache no longer has any | |
| association for user (see _wait_for_assoc_mgr_account).""" | |
| for t in atf.timer(fatal=True): | |
| output = atf.run_command_output( | |
| f"scontrol show assoc_mgr users={user} flags=assoc", fatal=True | |
| ) | |
| if f"UserName={user}(" not in output: | |
| break | |
| @pytest.fixture(scope="function") | |
| def create_local_user_assoc(create_accounts): | |
| """Create association for local user when AccountingStorageEnforce=associations.""" | |
| atf.run_command( | |
| f"sacctmgr -i add user {local_user_name} cluster={local_cluster_name} account={account_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| _wait_for_assoc_mgr_account(local_user_name, account_name) | |
| yield | |
| atf.run_command( | |
| f"sacctmgr -i delete user {local_user_name} cluster={local_cluster_name} account={account_name}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| @pytest.fixture(scope="function") | |
| def no_local_user_assoc(): | |
| """Ensure local_user_name has no association for the duration of the test. | |
| Removes any association before the test runs (so tests relying on this | |
| do not depend on another test's teardown having already removed one) | |
| and again afterwards, waiting for slurmctld's association cache to | |
| reflect the removal so job submissions aren't raced against a stale | |
| cache. | |
| """ | |
| atf.require_auto_config("deletes all associations of the local user") | |
| def _remove_and_wait(): | |
| atf.run_command( | |
| f"sacctmgr -i delete user {local_user_name} cluster={local_cluster_name}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| _wait_for_no_assoc_mgr_user(local_user_name) | |
| _remove_and_wait() | |
| yield | |
| _remove_and_wait() | |
| @pytest.fixture(scope="function") | |
| def create_coords(create_users): | |
| atf.run_command( | |
| f"sacctmgr -i create user {coord_name} cluster={local_cluster_name} account={account2_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| yield | |
| atf.cancel_all_jobs() | |
| _delete_user(f"{coord_name} cluster={local_cluster_name} account={account2_name}") | |
| @pytest.fixture(scope="function") | |
| def create_wckeys(): | |
| atf.run_command( | |
| f"sacctmgr -i create user {user_name} cluster={local_cluster_name} wckey={wckey_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i create user {coord_name} cluster={local_cluster_name} wckey={wckey_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| yield | |
| atf.cancel_all_jobs() | |
| _delete_user(f"{user_name} cluster={local_cluster_name} wckey={wckey_name}") | |
| _delete_user(f"{coord_name} cluster={local_cluster_name} wckey={wckey_name}") | |
| @pytest.fixture(scope="function") | |
| def create_qos(create_coords): | |
| atf.run_command( | |
| f"sacctmgr -i create qos {qos_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i create qos {qos2_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| yield | |
| atf.run_command( | |
| f"sacctmgr -i delete qos {qos_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i delete qos {qos2_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| @pytest.fixture(scope="function") | |
| def assoc_account(setup): | |
| acct = f"test-acct-assoc-{random.randrange(0, 99999999999)}" | |
| yield acct | |
| atf.run_command( | |
| f"sacctmgr -i delete account {acct}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| # sacctmgr exits 0 even when it refuses the delete, so confirm it happened. | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show account {acct} format=account", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"cleanup failed: account {acct} still exists" | |
| @pytest.fixture(scope="function") | |
| def assoc_user_account(setup): | |
| acct = f"test-user-assoc-acct-{random.randrange(0, 99999999999)}" | |
| user = f"test-user-assoc-{random.randrange(0, 99999999999)}" | |
| atf.run_command( | |
| f"sacctmgr -i create account {acct} cluster={local_cluster_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| yield user, acct | |
| # The first association added for a user with no default account becomes | |
| # that user's default account, and sacctmgr refuses to delete an account | |
| # that is still some user's default, so remove the user first or the | |
| # account delete below silently does nothing. | |
| atf.run_command( | |
| f"sacctmgr -i delete user {user}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i delete account {acct}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| # sacctmgr exits 0 even when it refuses the delete, so confirm it happened. | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show user {user} format=user", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"cleanup failed: user {user} still exists" | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show account {acct} format=account", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"cleanup failed: account {acct} still exists" | |
| @pytest.fixture(scope="function") | |
| def assoc_other_cluster(slurmdb, admin_level): | |
| from openapi_client.models.v0046_cluster_rec import V0046ClusterRec | |
| from openapi_client.models.v0046_openapi_clusters_resp import ( | |
| V0046OpenapiClustersResp, | |
| ) | |
| name = f"test-no-acct-cluster-{random.randrange(0, 99999999999)}" | |
| clusters = V0046OpenapiClustersResp(clusters=[V0046ClusterRec(name=name)]) | |
| resp = slurmdb.slurmdb_v0046_post_clusters(v0046_openapi_clusters_resp=clusters) | |
| assert resp.errors == [], f"failed to register {name}: {resp.errors}" | |
| yield name | |
| slurmdb.slurmdb_v0046_delete_cluster(name) | |
| # sacctmgr exits 0 even when it refuses the delete, so confirm it happened. | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show cluster {name} format=cluster", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"cleanup failed: cluster {name} still exists" | |
| @pytest.fixture(scope="function") | |
| def assoc_user(setup): | |
| user = f"test-user-assoc-{random.randrange(0, 99999999999)}" | |
| yield user | |
| atf.run_command( | |
| f"sacctmgr -i delete user {user}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| # sacctmgr exits 0 even when it refuses the delete, so confirm it happened. | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show user {user} format=user", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"cleanup failed: user {user} still exists" | |
| @pytest.fixture(scope="function") | |
| def dynamic_node(setup): | |
| """ | |
| Sets the required MaxNodeCount + cons_tres, and returns a non-existing node. | |
| """ | |
| nonexistent_node_name = "nonexistent_node" | |
| config = atf.get_config() | |
| atf.stop_slurm() | |
| atf.require_config_parameter("SelectType", "select/cons_tres") | |
| atf.require_config_parameter("SelectTypeParameters", "CR_CPU") | |
| atf.require_config_parameter("MaxNodeCount", str(req_node_count + 1)) | |
| atf.start_slurm() | |
| atf.run_command( | |
| f"scontrol delete node {nonexistent_node_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| xfail=True, | |
| ) | |
| yield nonexistent_node_name | |
| atf.run_command( | |
| f"scontrol delete node {nonexistent_node_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| atf.stop_slurm() | |
| atf.require_config_parameter("SelectType", config["SelectType"]) | |
| atf.require_config_parameter("SelectTypeParameters", config["SelectTypeParameters"]) | |
| atf.require_config_parameter("MaxNodeCount", None) | |
| atf.start_slurm() | |
| @pytest.fixture(scope="function") | |
| def cleanup_created_user2(): | |
| yield | |
| atf.run_command( | |
| f"sacctmgr -i delete user {user_name2} " | |
| f"cluster={local_cluster_name} account={account_name}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| @pytest.fixture(scope="function") | |
| def cleanup_created_account3(): | |
| yield | |
| atf.run_command( | |
| f"sacctmgr -i delete account {account3_name}", | |
| user=atf.properties["slurm-user"], | |
| ) | |
| def test_loaded_versions(): | |
| r = atf.request_slurmrestd("openapi/v3") | |
| assert r.status_code == 200 | |
| spec = r.json() | |
| # verify older plugins are not loaded | |
| assert "/slurm/v0.0.45/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.44/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.43/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.42/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.41/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.40/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.39/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.38/jobs" not in spec["paths"].keys() | |
| assert "/slurmdb/v0.0.38/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.37/jobs" not in spec["paths"].keys() | |
| assert "/slurmdb/v0.0.37/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.36/jobs" not in spec["paths"].keys() | |
| assert "/slurmdb/v0.0.36/jobs" not in spec["paths"].keys() | |
| assert "/slurm/v0.0.35/jobs" not in spec["paths"].keys() | |
| # verify current plugins are loaded | |
| assert "/slurm/v0.0.46/jobs/" in spec["paths"].keys() | |
| assert "/slurmdb/v0.0.46/jobs/" in spec["paths"].keys() | |
| @pytest.mark.skipif( | |
| atf.get_version() <= (26, 11, 0), reason="Specs may change until .0 is released" | |
| ) | |
| @pytest.mark.parametrize("openapi_spec", ["46"], indirect=True) | |
| def test_specification(openapi_spec): | |
| if atf.get_version("sbin/slurmrestd") >= (28, 5): | |
| # This is expected to be deprecated in 28.05+ | |
| patch = atf.get_deprecated_openapi_spec_patch(openapi_spec) | |
| patch.apply(openapi_spec, in_place=True) | |
| atf.assert_openapi_spec_eq(openapi_spec, atf.properties["openapi_spec"]) | |
| def test_specification_no_empty_required(): | |
| """No schema in the spec has an empty "required" list | |
| The spec declares OpenAPI 3.0.3, which takes "required" from JSON Schema | |
| Wright Draft 00: the list must have at least one element. An object with | |
| no required fields has to omit the keyword instead. | |
| """ | |
| r = atf.request_slurmrestd("openapi/v3") | |
| assert r.status_code == 200, f"Unable to get the OpenAPI spec: {r.text}" | |
| spec = r.json() | |
| def _find_empty_required(oas, path): | |
| """Returns the paths of every object in oas with "required": []""" | |
| found = [] | |
| if type(oas) is dict: | |
| if oas.get("required") == []: | |
| found.append(path) | |
| for key, value in oas.items(): | |
| found += _find_empty_required(value, f"{path}/{key}") | |
| elif type(oas) is list: | |
| for i, value in enumerate(oas): | |
| found += _find_empty_required(value, f"{path}/{i}") | |
| return found | |
| empty = _find_empty_required(spec, "#") | |
| assert not empty, f"Schemas with an empty required list: {empty}" | |
| # Objects with required fields must keep their non-empty list | |
| assert any( | |
| schema.get("required") for schema in spec["components"]["schemas"].values() | |
| ), "No schema has a non-empty required list" | |
| def test_db_accounts(slurm, slurmdb, create_wckeys, admin_level): | |
| from openapi_client import ApiClient as Client | |
| from openapi_client import Configuration as Config | |
| from openapi_client.models.v0046_account import V0046Account | |
| from openapi_client.models.v0046_assoc_short import V0046AssocShort | |
| from openapi_client.models.v0046_coord import V0046Coord | |
| from openapi_client.models.v0046_openapi_accounts_resp import ( | |
| V0046OpenapiAccountsResp, | |
| ) | |
| # make sure account doesn't already exist | |
| resp = slurmdb.slurmdb_v0046_get_account_with_http_info(account_name) | |
| assert resp.status_code == 200 | |
| assert len(resp.data.accounts) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_account_with_http_info(account2_name) | |
| assert resp.status_code == 200 | |
| assert len(resp.data.accounts) == 0 | |
| # create account | |
| accounts = V0046OpenapiAccountsResp( | |
| accounts=[ | |
| V0046Account( | |
| description="test description", | |
| name=account_name, | |
| organization="test organization", | |
| ), | |
| V0046Account( | |
| coordinators=[ | |
| V0046Coord( | |
| name=coord_name, | |
| ) | |
| ], | |
| description="test description", | |
| name=account2_name, | |
| organization="test organization", | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_accounts_with_http_info(accounts) | |
| assert resp.status_code == 200 | |
| accounts = V0046OpenapiAccountsResp( | |
| accounts=[ | |
| V0046Account( | |
| name=account2_name, | |
| description="fail description", | |
| organization="fail organization", | |
| ) | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_accounts_with_http_info(accounts) | |
| assert resp.status_code == 200 | |
| # verify account matches modify request | |
| resp = slurmdb.slurmdb_v0046_get_account(account2_name) | |
| assert resp.accounts | |
| for account in resp.accounts: | |
| assert account.name == account2_name | |
| assert account.description == accounts.accounts[0].description | |
| assert account.organization == accounts.accounts[0].organization | |
| assert not account.flags | |
| # change account desc and org | |
| accounts = V0046OpenapiAccountsResp( | |
| accounts=[ | |
| V0046Account( | |
| coordinators=[], | |
| description="test description modified", | |
| name=account2_name, | |
| organization="test organization modified", | |
| ) | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_accounts(accounts) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_account(account2_name) | |
| assert resp.accounts | |
| for account in resp.accounts: | |
| assert account.name == account2_name | |
| assert account.description == accounts.accounts[0].description | |
| assert account.organization == accounts.accounts[0].organization | |
| assert not account.coordinators | |
| resp = slurmdb.slurmdb_v0046_get_account(account_name) | |
| assert resp.accounts | |
| for account in resp.accounts: | |
| assert account.name == account_name | |
| # check full listing works | |
| resp = slurmdb.slurmdb_v0046_get_accounts(deleted="true") | |
| assert resp.accounts | |
| resp = slurmdb.slurmdb_v0046_get_accounts() | |
| assert resp.accounts | |
| accounts = V0046OpenapiAccountsResp( | |
| accounts=[ | |
| V0046Account( | |
| coordinators=[], | |
| description="test description modified", | |
| name=account2_name, | |
| organization="test organization modified", | |
| ) | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_delete_account(account_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_delete_account(account2_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_account(account_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.accounts | |
| resp = slurmdb.slurmdb_v0046_get_account(account2_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.accounts | |
| def test_db_accounts_null_meta(slurmdb, admin_level, cleanup_created_account3): | |
| """Issue 51205: POST /accounts/ accepts an explicit "meta": null. | |
| data_parser registers meta as a null-allowed pointer and dumps it as null | |
| when unset (the OpenAPI schema does not mark it nullable), so a null must | |
| parse back to an unset meta rather than be rejected. The generated client | |
| cannot send this: it drops None-valued fields. | |
| """ | |
| resp = slurmdb.slurmdb_v0046_get_account(account3_name) | |
| assert not resp.accounts, f"account {account3_name} already exists" | |
| body = { | |
| "meta": None, | |
| "accounts": [ | |
| { | |
| "name": account3_name, | |
| "description": "test description", | |
| "organization": "test organization", | |
| } | |
| ], | |
| } | |
| resp = _slurmrestd_post("/slurmdb/v0.0.46/accounts/", body) | |
| assert not resp.get("errors"), resp.get("errors") | |
| assert not resp.get("warnings"), resp.get("warnings") | |
| resp = slurmdb.slurmdb_v0046_get_account(account3_name) | |
| assert len(resp.errors) == 0, resp.errors | |
| assert resp.accounts, "account posted with a null meta was not created" | |
| def test_db_diag(slurmdb, admin_level): | |
| resp = slurmdb.slurmdb_v0046_get_diag() | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.statistics.time_start > 0 | |
| def test_db_wckeys(slurmdb, create_coords, admin_level): | |
| from openapi_client.models.v0046_openapi_wckey_resp import V0046OpenapiWckeyResp | |
| from openapi_client.models.v0046_wckey import V0046Wckey | |
| wckeys = V0046OpenapiWckeyResp( | |
| wckeys=[ | |
| V0046Wckey( | |
| cluster=local_cluster_name, | |
| name=wckey_name, | |
| user=user_name, | |
| ), | |
| V0046Wckey( | |
| cluster=local_cluster_name, | |
| name=wckey2_name, | |
| user=user_name, | |
| ), | |
| V0046Wckey( | |
| cluster=local_cluster_name, | |
| name=wckey2_name, | |
| user=coord_name, | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_wckeys_with_http_info( | |
| v0046_openapi_wckey_resp=wckeys | |
| ) | |
| assert resp.status_code == 200 | |
| resp = slurmdb.slurmdb_v0046_get_wckeys() | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert len(resp.wckeys) >= 1 | |
| resp = slurmdb.slurmdb_v0046_get_wckey(wckey_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.wckeys | |
| for wckey in resp.wckeys: | |
| assert wckey.name == wckey_name or wckey.name == wckey2_name | |
| assert wckey.user == user_name or wckey.user == coord_name | |
| resp = slurmdb.slurmdb_v0046_get_wckey(wckey2_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.wckeys | |
| for wckey in resp.wckeys: | |
| assert wckey.name == wckey2_name | |
| assert wckey.user == user_name or wckey.user == coord_name | |
| resp = slurmdb.slurmdb_v0046_delete_wckey(wckey_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_delete_wckey(wckey2_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_wckey(wckey_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert len(resp.wckeys) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_wckey(wckey2_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert len(resp.wckeys) == 0 | |
| def test_db_clusters(slurmdb, admin_level): | |
| from openapi_client.models.v0046_cluster_rec import V0046ClusterRec | |
| from openapi_client.models.v0046_openapi_clusters_resp import ( | |
| V0046OpenapiClustersResp, | |
| ) | |
| clusters = V0046OpenapiClustersResp( | |
| clusters=[ | |
| V0046ClusterRec( | |
| name=cluster_name, | |
| ), | |
| V0046ClusterRec( | |
| name=cluster2_name, | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_clusters(v0046_openapi_clusters_resp=clusters) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_clusters() | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.clusters | |
| resp = slurmdb.slurmdb_v0046_get_cluster(cluster_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.clusters | |
| for cluster in resp.clusters: | |
| assert cluster.name == cluster_name | |
| assert not cluster.nodes | |
| resp = slurmdb.slurmdb_v0046_get_cluster(cluster2_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.clusters | |
| for cluster in resp.clusters: | |
| assert cluster.name == cluster2_name | |
| assert not cluster.nodes | |
| resp = slurmdb.slurmdb_v0046_delete_cluster(cluster_name) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_delete_cluster(cluster2_name) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_cluster(cluster_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.clusters | |
| resp = slurmdb.slurmdb_v0046_get_cluster(cluster2_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.clusters | |
| def test_db_users(slurmdb, admin_level): | |
| from openapi_client.models.v0046_assoc_short import V0046AssocShort | |
| from openapi_client.models.v0046_coord import V0046Coord | |
| from openapi_client.models.v0046_openapi_users_resp import V0046OpenapiUsersResp | |
| from openapi_client.models.v0046_user import V0046User | |
| from openapi_client.models.v0046_user_default import V0046UserDefault | |
| from openapi_client.models.v0046_wckey import V0046Wckey | |
| users = V0046OpenapiUsersResp( | |
| users=[ | |
| V0046User( | |
| administrator_level=["None"], | |
| default=dict( | |
| wckey=wckey_name, | |
| ), | |
| name=user_name, | |
| ), | |
| V0046User( | |
| administrator_level=["Operator"], | |
| wckeys=[ | |
| V0046Wckey( | |
| cluster=local_cluster_name, name=wckey_name, user=coord_name | |
| ), | |
| V0046Wckey( | |
| cluster=local_cluster_name, | |
| name=wckey2_name, | |
| user=coord_name, | |
| ), | |
| ], | |
| default=V0046UserDefault( | |
| wckey=wckey2_name, | |
| ), | |
| name=coord_name, | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_users(v0046_openapi_users_resp=users) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_users() | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.users | |
| # Using query parameters (i.e. with_wckeys/with_deleted) results in warnings | |
| # Slurmrestd expected OpenAPI type=boolean but got OpenAPI type=string | |
| resp = slurmdb.slurmdb_v0046_get_user(user_name, with_wckeys="true") | |
| if resp.warnings: | |
| assert len(resp.warnings) == 1 | |
| assert resp.warnings[0].source == "#/with_wckeys/" | |
| assert len(resp.errors) == 0 | |
| assert resp.users | |
| for user in resp.users: | |
| assert user.name == user_name | |
| assert user.default.wckey == wckey_name | |
| resp = slurmdb.slurmdb_v0046_get_user(coord_name, with_wckeys="true") | |
| if resp.warnings: | |
| assert len(resp.warnings) == 1 | |
| assert resp.warnings[0].source == "#/with_wckeys/" | |
| assert len(resp.errors) == 0 | |
| assert resp.users | |
| for user in resp.users: | |
| assert user.name == coord_name | |
| assert user.default.wckey == wckey2_name | |
| for wckey in user.wckeys: | |
| assert wckey.name == wckey_name or wckey.name == wckey2_name | |
| assert wckey.user == coord_name | |
| assert wckey.cluster == local_cluster_name | |
| resp = slurmdb.slurmdb_v0046_delete_user(coord_name) | |
| assert len(resp.errors) == 0 | |
| user_exists = False | |
| resp = slurmdb.slurmdb_v0046_get_user(coord_name, with_deleted="true") | |
| assert len(resp.errors) == 0 | |
| for user in resp.users: | |
| assert user.name == coord_name | |
| assert user.flags[0] == "DELETED" | |
| user_exists = True | |
| if not user_exists: | |
| users = V0046OpenapiUsersResp( | |
| users=[ | |
| V0046User( | |
| administrator_level=["Administrator"], | |
| default=dict( | |
| wckey=wckey_name, | |
| ), | |
| old_name=user_name, | |
| name=coord_name, | |
| ) | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_users(v0046_openapi_users_resp=users) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_user(coord_name, with_wckeys="true") | |
| if resp.warnings: | |
| assert len(resp.warnings) == 1 | |
| assert resp.warnings[0].source == "#/with_wckeys/" | |
| assert len(resp.errors) == 0 | |
| assert resp.users | |
| for user in resp.users: | |
| assert user.name == coord_name | |
| assert not user.old_name | |
| assert user.default.wckey == wckey_name | |
| for wckey in user.wckeys: | |
| assert wckey.name == wckey_name | |
| assert wckey.user == coord_name | |
| assert wckey.cluster == local_cluster_name | |
| resp = slurmdb.slurmdb_v0046_delete_user(coord_name) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_user(coord_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.users | |
| def test_db_assoc(slurmdb, create_coords, create_qos, admin_level): | |
| from openapi_client.models.v0046_assoc import V0046Assoc | |
| from openapi_client.models.v0046_assoc_short import V0046AssocShort | |
| from openapi_client.models.v0046_coord import V0046Coord | |
| from openapi_client.models.v0046_openapi_assocs_resp import V0046OpenapiAssocsResp | |
| from openapi_client.models.v0046_shares_struct import ( | |
| V0046SharesStruct as V0046Shares, | |
| ) | |
| from openapi_client.models.v0046_uint32_no_val_struct import ( | |
| V0046Uint32NoValStruct as V0046Uint32NoVal, | |
| ) | |
| from openapi_client.models.v0046_user import V0046User | |
| from openapi_client.models.v0046_wckey import V0046Wckey | |
| associations = V0046OpenapiAssocsResp( | |
| associations=[ | |
| V0046Assoc( | |
| account=account_name, | |
| cluster=local_cluster_name, | |
| default=dict( | |
| qos=qos_name, | |
| ), | |
| flags=[], | |
| max=dict( | |
| jobs=dict( | |
| per=dict( | |
| wall_clock=V0046Uint32NoVal( | |
| set=True, | |
| number=150, | |
| ) | |
| ), | |
| ), | |
| ), | |
| min=dict( | |
| priority_threshold=V0046Uint32NoVal( | |
| set=True, | |
| number=10, | |
| ) | |
| ), | |
| partition=partition_name, | |
| priority=V0046Uint32NoVal(number=9, set=True), | |
| qos=[qos_name, qos2_name], | |
| shares_raw=V0046Shares(set=True, number=23), | |
| user=user_name, | |
| ), | |
| V0046Assoc( | |
| account=account_name, | |
| cluster=local_cluster_name, | |
| default=dict( | |
| qos=qos_name, | |
| ), | |
| flags=[], | |
| max=dict( | |
| jobs=dict( | |
| per=dict( | |
| wall_clock=V0046Uint32NoVal( | |
| set=True, | |
| number=150, | |
| ) | |
| ), | |
| ), | |
| ), | |
| min=dict( | |
| priority_threshold=V0046Uint32NoVal( | |
| set=True, | |
| number=10, | |
| ) | |
| ), | |
| priority=V0046Uint32NoVal(number=9, set=True), | |
| qos=[qos_name, qos2_name], | |
| shares_raw=V0046Shares(set=True, number=23), | |
| user=user_name, | |
| ), | |
| V0046Assoc( | |
| account=account2_name, | |
| cluster=local_cluster_name, | |
| default=dict( | |
| qos=qos2_name, | |
| ), | |
| flags=[], | |
| max=dict( | |
| jobs=dict( | |
| per=dict( | |
| wall_clock=V0046Uint32NoVal( | |
| set=True, | |
| number=50, | |
| ) | |
| ), | |
| ), | |
| ), | |
| min=dict( | |
| priority_threshold=V0046Uint32NoVal( | |
| set=True, | |
| number=4, | |
| ) | |
| ), | |
| partition=partition_name, | |
| priority=V0046Uint32NoVal(number=90, set=True), | |
| qos=[qos2_name], | |
| shares_raw=V0046Shares(set=True, number=1012), | |
| user=user_name, | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_associations( | |
| v0046_openapi_assocs_resp=associations | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_associations() | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.associations | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name, | |
| partition=partition_name, | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.associations | |
| for assoc in resp.associations: | |
| assert assoc.cluster == local_cluster_name | |
| assert assoc.account == account_name | |
| assert assoc.user == user_name | |
| assert assoc.partition == partition_name | |
| assert assoc.default.qos == qos_name | |
| assert not assoc.flags | |
| assert assoc.max.jobs.per.wall_clock.set | |
| assert assoc.max.jobs.per.wall_clock.number == 150 | |
| assert assoc.min.priority_threshold.set | |
| assert assoc.min.priority_threshold.number == 10 | |
| assert assoc.priority.set | |
| assert assoc.priority.number == 9 | |
| for qos in assoc.qos: | |
| assert qos == qos_name or qos == qos2_name | |
| assert assoc.shares_raw.set | |
| assert not assoc.shares_raw.parent | |
| assert assoc.shares_raw.number == 23 | |
| associations = V0046OpenapiAssocsResp( | |
| associations=[ | |
| V0046Assoc( | |
| account=account_name, | |
| cluster=local_cluster_name, | |
| partition=partition_name, | |
| user=user_name, | |
| default=dict( | |
| qos=qos2_name, | |
| ), | |
| qos=[qos2_name], | |
| max=dict( | |
| jobs=dict( | |
| per=dict(wall_clock=V0046Uint32NoVal(set=True, number=250)), | |
| ), | |
| ), | |
| min=dict( | |
| priority_threshold=V0046Uint32NoVal(set=True, number=100), | |
| ), | |
| priority=V0046Uint32NoVal(number=848, set=True), | |
| shares_raw=V0046Shares(set=True, number=230), | |
| ) | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_associations( | |
| v0046_openapi_assocs_resp=associations | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name, | |
| partition=partition_name, | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.associations | |
| for assoc in resp.associations: | |
| assert assoc.cluster == local_cluster_name | |
| assert assoc.account == account_name | |
| assert assoc.user == user_name | |
| assert assoc.partition == partition_name | |
| assert assoc.default.qos == qos2_name | |
| assert not assoc.flags | |
| assert assoc.max.jobs.per.wall_clock.set | |
| assert assoc.max.jobs.per.wall_clock.number == 250 | |
| assert assoc.min.priority_threshold.set | |
| assert assoc.min.priority_threshold.number == 100 | |
| assert assoc.priority.set | |
| assert assoc.priority.number == 848 | |
| for qos in assoc.qos: | |
| assert qos == qos2_name | |
| assert assoc.shares_raw.set | |
| assert not assoc.shares_raw.parent | |
| assert assoc.shares_raw.number == 230 | |
| # fairshare=parent (SLURMDB_FS_USE_PARENT) instead of a share count | |
| associations = V0046OpenapiAssocsResp( | |
| associations=[ | |
| V0046Assoc( | |
| account=account_name, | |
| cluster=local_cluster_name, | |
| partition=partition_name, | |
| user=user_name, | |
| shares_raw=V0046Shares(parent=True), | |
| ) | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_associations( | |
| v0046_openapi_assocs_resp=associations | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name, | |
| partition=partition_name, | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.associations | |
| for assoc in resp.associations: | |
| assert assoc.shares_raw.parent | |
| assert not assoc.shares_raw.set | |
| # A fairshare-only POST must not clobber the other limits the previous | |
| # modify set on this association (they survive because the parser leaves | |
| # omitted fields at NO_VAL and slurmdbd skips NO_VAL). | |
| assert assoc.cluster == local_cluster_name | |
| assert assoc.account == account_name | |
| assert assoc.user == user_name | |
| assert assoc.partition == partition_name | |
| assert assoc.default.qos == qos2_name | |
| assert assoc.priority.set | |
| assert assoc.priority.number == 848 | |
| assert assoc.max.jobs.per.wall_clock.set | |
| assert assoc.max.jobs.per.wall_clock.number == 250 | |
| assert assoc.min.priority_threshold.set | |
| assert assoc.min.priority_threshold.number == 100 | |
| resp = slurmdb.slurmdb_v0046_delete_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name, | |
| partition=partition_name, | |
| ) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name, | |
| partition=partition_name, | |
| ) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.associations | |
| resp = slurmdb.slurmdb_v0046_delete_associations( | |
| cluster=local_cluster_name, | |
| user=user_name, | |
| ) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_delete_associations( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| ) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| ) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.associations | |
| resp = slurmdb.slurmdb_v0046_delete_associations( | |
| cluster=local_cluster_name, | |
| account=account2_name, | |
| ) | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account2_name, | |
| ) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.associations | |
| def test_db_assoc_and_qos_tres_decay_half_life( | |
| slurmdb, create_coords, create_qos, admin_level | |
| ): | |
| """TresDecayHalfLife round-trips through the v0.0.46 assoc and QOS fields. | |
| Ticket 50920: added tres/decay/half_life on the assoc and | |
| limits/tres/decay/half_life on the QOS. A bare number is stored and read | |
| back in seconds by both sacctmgr and the REST API | |
| (test_102_17.py:210-212 pins that agreement on the sacctmgr side); this | |
| is the REST half of that same claim. cpu=0 is included since it is the | |
| value that actually matters -- it is what makes a GrpTRESMins quota | |
| non-replenishing. | |
| """ | |
| from openapi_client.models.v0046_assoc import V0046Assoc | |
| from openapi_client.models.v0046_openapi_assocs_resp import V0046OpenapiAssocsResp | |
| from openapi_client.models.v0046_openapi_slurmdbd_qos_resp import ( | |
| V0046OpenapiSlurmdbdQosResp, | |
| ) | |
| from openapi_client.models.v0046_qos import V0046Qos | |
| from openapi_client.models.v0046_tres import V0046Tres | |
| associations = V0046OpenapiAssocsResp( | |
| associations=[ | |
| V0046Assoc( | |
| account=account_name, | |
| cluster=local_cluster_name, | |
| user=user_name, | |
| tres=dict( | |
| decay=dict( | |
| half_life=[ | |
| V0046Tres(type="cpu", count=0), | |
| V0046Tres(type="mem", count=1800), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_associations( | |
| v0046_openapi_assocs_resp=associations | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name, | |
| ) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.associations | |
| for assoc in resp.associations: | |
| assert assoc.tres.decay.half_life | |
| for tres in assoc.tres.decay.half_life: | |
| assert tres.type == "cpu" or tres.type == "mem" | |
| if tres.type == "cpu": | |
| assert tres.count == 0 | |
| if tres.type == "mem": | |
| assert tres.count == 1800 | |
| qos = V0046OpenapiSlurmdbdQosResp( | |
| qos=[ | |
| V0046Qos( | |
| name=qos_name, | |
| limits=dict( | |
| tres=dict( | |
| decay=dict( | |
| half_life=[ | |
| V0046Tres(type="cpu", count=0), | |
| V0046Tres(type="mem", count=1800), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_qos(v0046_openapi_slurmdbd_qos_resp=qos) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_single_qos(qos_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.qos | |
| for qos in resp.qos: | |
| assert qos.limits.tres.decay.half_life | |
| for tres in qos.limits.tres.decay.half_life: | |
| assert tres.type == "cpu" or tres.type == "mem" | |
| if tres.type == "cpu": | |
| assert tres.count == 0 | |
| if tres.type == "mem": | |
| assert tres.count == 1800 | |
| def _slurmrestd_post(path, body, expect_status=200, expect_error=False): | |
| """POST raw JSON to slurmrestd and return the decoded response. | |
| Used for request shapes the generated OpenAPI client cannot express: the | |
| bare string "parent" and a bare integer/null shares value (the field's | |
| generated model is the V0046SharesStruct object), and an explicit null | |
| meta (the client omits unset fields). | |
| """ | |
| r = requests.post( | |
| f"{atf.properties['slurmrestd_url']}{path}", | |
| headers=atf.properties["slurmrestd-headers"], | |
| json=body, | |
| ) | |
| if expect_error: | |
| assert r.status_code >= 400, f"POST {path} -> {r.status_code}: {r.text[:300]}" | |
| else: | |
| assert ( | |
| r.status_code == expect_status | |
| ), f"POST {path} -> {r.status_code}: {r.text[:300]}" | |
| return r.json() | |
| def test_db_assoc_create_user_fairshare_parent( | |
| slurmdb, create_accounts, admin_level, cleanup_created_user2 | |
| ): | |
| """Creating a user association in fairshare=parent mode via POST | |
| /users_association/ (ASSOC_REC_SET.fairshare) -- the creation path bug 25581 | |
| is about, which the modify-path coverage in test_db_assoc does not exercise. | |
| """ | |
| from openapi_client.models.v0046_assoc_rec_set import V0046AssocRecSet | |
| from openapi_client.models.v0046_openapi_users_add_cond_resp import ( | |
| V0046OpenapiUsersAddCondResp, | |
| ) | |
| from openapi_client.models.v0046_shares_struct import V0046SharesStruct | |
| from openapi_client.models.v0046_user_short import V0046UserShort | |
| from openapi_client.models.v0046_users_add_cond import V0046UsersAddCond | |
| req = V0046OpenapiUsersAddCondResp( | |
| association_condition=V0046UsersAddCond( | |
| accounts=[account_name], | |
| clusters=[local_cluster_name], | |
| users=[user_name2], | |
| association=V0046AssocRecSet(fairshare=V0046SharesStruct(parent=True)), | |
| ), | |
| user=V0046UserShort(), | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_users_association( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| assert not resp.warnings, resp.warnings | |
| assert len(resp.errors) == 0, resp.errors | |
| got = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name2, | |
| ) | |
| assert len(got.errors) == 0, got.errors | |
| assert got.associations, "created user association was not returned" | |
| for assoc in got.associations: | |
| assert assoc.shares_raw.parent, "created assoc not in parent mode" | |
| assert not assoc.shares_raw.set, "parent assoc must not set a share count" | |
| # Cross-check through a second, independent interface: sacctmgr reads the | |
| # stored value via assoc_mgr/DB, not the REST parse/dump, and prints the | |
| # literal "parent" for SLURMDB_FS_USE_PARENT. This catches a symmetric | |
| # parse<->dump mis-mapping the REST round-trip alone would hide (e.g. parent | |
| # mapped onto INFINITE, which would persist as 1 share). | |
| share = atf.run_command_output( | |
| f"sacctmgr -n -P show assoc cluster={local_cluster_name} " | |
| f"account={account_name} user={user_name2} format=Share", | |
| fatal=True, | |
| ).strip() | |
| assert share == "parent", f"sacctmgr Share should be parent, got {share!r}" | |
| def test_db_assoc_create_user_fairshare_parent_string( | |
| slurmdb, create_accounts, admin_level, cleanup_created_user2 | |
| ): | |
| """Creating a user association with the bare string "parent" via POST | |
| /users_association/ -- the exact payload reported in bug 25581. The | |
| generated client cannot express it: the schema types fairshare as the | |
| V0046SharesStruct object. | |
| """ | |
| body = { | |
| "association_condition": { | |
| "accounts": [account_name], | |
| "clusters": [local_cluster_name], | |
| "users": [user_name2], | |
| "association": {"fairshare": "parent"}, | |
| }, | |
| "user": {}, | |
| } | |
| resp = _slurmrestd_post("/slurmdb/v0.0.46/users_association/", body) | |
| assert not resp.get("errors"), resp.get("errors") | |
| assert not resp.get("warnings"), resp.get("warnings") | |
| got = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account_name, | |
| user=user_name2, | |
| ) | |
| assert len(got.errors) == 0, got.errors | |
| assert got.associations, "created user association was not returned" | |
| for assoc in got.associations: | |
| assert assoc.shares_raw.parent, "created assoc not in parent mode" | |
| assert not assoc.shares_raw.set, "parent assoc must not set a share count" | |
| def test_db_assoc_create_account_fairshare_parent_string( | |
| slurmdb, admin_level, cleanup_created_account3 | |
| ): | |
| """Creating an account association via POST /accounts_association/ using the | |
| bare string "parent" (the sacctmgr keyword form; the generated client can | |
| only send the struct). Covers both the accounts creation endpoint and the | |
| DATA_TYPE_STRING branch of PARSE_FUNC(SHARES). | |
| """ | |
| body = { | |
| "association_condition": { | |
| "accounts": [account3_name], | |
| "clusters": [local_cluster_name], | |
| "association": {"fairshare": "parent"}, | |
| } | |
| } | |
| resp = _slurmrestd_post("/slurmdb/v0.0.46/accounts_association/", body) | |
| assert not resp.get("errors"), resp.get("errors") | |
| assert not resp.get("warnings"), resp.get("warnings") | |
| got = slurmdb.slurmdb_v0046_get_association( | |
| cluster=local_cluster_name, | |
| account=account3_name, | |
| ) | |
| assert len(got.errors) == 0, got.errors | |
| assert got.associations, "created account association was not returned" | |
| for assoc in got.associations: | |
| assert assoc.shares_raw.parent, "created account assoc not in parent mode" | |
| assert not assoc.shares_raw.set, "parent assoc must not set a share count" | |
| def test_db_assoc_shares_infinite_and_unset(slurmdb, create_users, admin_level): | |
| """The accepted SHARES forms on ASSOC.shares_raw: a bare -1 resets shares to | |
| 1 (like sacctmgr FairShare=-1), null leaves the stored value unchanged (no | |
| shares= clause emitted), the bare string "parent" selects parent mode, and | |
| the struct discriminators resolve in the documented order parent > infinite | |
| > set/number. The null case is the dangerous one -- a regression there | |
| silently rewrites the share count of any association updated without | |
| mentioning fairshare. | |
| """ | |
| ident = { | |
| "cluster": local_cluster_name, | |
| "account": account_name, | |
| "user": user_name, | |
| } | |
| def get_shares(): | |
| got = slurmdb.slurmdb_v0046_get_association(**ident) | |
| assert len(got.errors) == 0, got.errors | |
| assert ( | |
| len(got.associations) == 1 | |
| ), f"expected exactly one association, got {got.associations}" | |
| return got.associations[0].shares_raw | |
| def post_shares(value): | |
| _slurmrestd_post( | |
| "/slurmdb/v0.0.46/associations/", | |
| {"associations": [{**ident, "shares_raw": value}]}, | |
| ) | |
| # Seed a known share count, then -1 must reset it to a single share. | |
| post_shares(55) | |
| shares = get_shares() | |
| assert shares.set and shares.number == 55, "seed of 55 shares did not take" | |
| post_shares(-1) | |
| shares = get_shares() | |
| assert shares.set and shares.number == 1, "fairshare=-1 should reset to 1 share" | |
| # Re-seed, then null must leave the value untouched. | |
| post_shares(77) | |
| assert get_shares().number == 77, "re-seed of 77 shares did not take" | |
| post_shares(None) | |
| shares = get_shares() | |
| assert shares.set and shares.number == 77, "shares=null must not change shares" | |
| # Same two behaviors via the SHARES struct fields (distinct parser | |
| # branches from the bare integer/null above): infinite=True resets to 1, and | |
| # set=False leaves the value unchanged. These are the forms an OpenAPI client | |
| # sending the generated V0046SharesStruct model actually uses. | |
| post_shares(55) | |
| post_shares({"infinite": True}) | |
| shares = get_shares() | |
| assert shares.set and shares.number == 1, "shares={infinite} should reset to 1" | |
| post_shares(88) | |
| post_shares({"set": False}) | |
| assert get_shares().number == 88, "shares={set:false} must not change shares" | |
| # An update that omits shares_raw entirely must also leave it unchanged (the | |
| # field stays NO_VAL via slurmdb_init_assoc_rec), explicitly not reset to 0. | |
| _slurmrestd_post( | |
| "/slurmdb/v0.0.46/associations/", | |
| {"associations": [{**ident, "priority": {"set": True, "number": 5}}]}, | |
| ) | |
| got = slurmdb.slurmdb_v0046_get_association(**ident) | |
| assert len(got.errors) == 0, got.errors | |
| assert got.associations, "association not found" | |
| assert got.associations[0].priority.number == 5, "the modify did not land" | |
| shares = got.associations[0].shares_raw | |
| assert ( | |
| shares.set and shares.number == 88 | |
| ), "omitting fairshare must not change shares" | |
| # The bare string form bug 25581 reported against this endpoint. | |
| post_shares("parent") | |
| shares = get_shares() | |
| assert shares.parent, 'shares_raw="parent" should select parent mode' | |
| assert not shares.set, "parent assoc must not set a share count" | |
| # Precedence: "parent" outranks every other discriminator, and "infinite" | |
| # outranks "set"/"number". | |
| post_shares(99) | |
| shares = get_shares() | |
| assert ( | |
| shares.set and shares.number == 99 and not shares.parent | |
| ), "a numeric POST must clear parent mode" | |
| post_shares({"parent": True, "infinite": True, "set": True, "number": 42}) | |
| shares = get_shares() | |
| assert shares.parent, "parent must outrank infinite and set/number" | |
| assert not shares.set, "parent assoc must not set a share count" | |
| post_shares(99) | |
| post_shares({"infinite": True, "set": True, "number": 42}) | |
| shares = get_shares() | |
| assert shares.set and shares.number == 1, "infinite must outrank set/number" | |
| def test_db_assoc_shares_rejects_invalid(slurmdb, create_users, admin_level): | |
| """An unrecognized fairshare string is rejected and the stored share count | |
| survives. sacctmgr.1 documents the value set as {<fairshare_number>|parent}, | |
| so anything outside it must not silently become a share count. | |
| """ | |
| ident = { | |
| "cluster": local_cluster_name, | |
| "account": account_name, | |
| "user": user_name, | |
| } | |
| def get_shares(): | |
| got = slurmdb.slurmdb_v0046_get_association(**ident) | |
| assert len(got.errors) == 0, got.errors | |
| assert ( | |
| len(got.associations) == 1 | |
| ), f"expected exactly one association, got {got.associations}" | |
| return got.associations[0].shares_raw | |
| _slurmrestd_post( | |
| "/slurmdb/v0.0.46/associations/", | |
| {"associations": [{**ident, "shares_raw": 33}]}, | |
| ) | |
| assert get_shares().number == 33, "seed of 33 shares did not take" | |
| resp = _slurmrestd_post( | |
| "/slurmdb/v0.0.46/associations/", | |
| {"associations": [{**ident, "shares_raw": "parnet"}]}, | |
| expect_error=True, | |
| ) | |
| assert resp["errors"], "an unrecognized fairshare string must be reported" | |
| shares = get_shares() | |
| assert shares.set and shares.number == 33, "rejected input must not change shares" | |
| def test_shares_render_parent(slurm, create_users, admin_level): | |
| """GET /slurm/v0.0.46/shares renders an inherited shares value as "parent". | |
| This is the third SHARES use-site (ASSOC_SHARES_OBJ_WRAP.shares_raw, a | |
| parser instance distinct from the ASSOC and ASSOC_REC_SET write parsers), | |
| so its dump of the parent sentinel is otherwise unobserved. Reads through | |
| slurmctld's assoc_mgr, which updates asynchronously from slurmdbd. | |
| """ | |
| atf.run_command( | |
| f"sacctmgr -i modify account {account_name} where " | |
| f"cluster={local_cluster_name} set fairshare=7", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| atf.run_command( | |
| f"sacctmgr -i modify user {user_name} where cluster={local_cluster_name} " | |
| f"account={account_name} set fairshare=parent", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| entry = None | |
| account_entry = None | |
| for _ in atf.timer(fatal=True): | |
| resp = slurm.slurm_v0046_get_shares(accounts=account_name, users=user_name) | |
| assert len(resp.errors) == 0, resp.errors | |
| entry = next((s for s in resp.shares.shares if s.name == user_name), None) | |
| account_entry = next( | |
| (s for s in resp.shares.shares if s.name == account_name), None | |
| ) | |
| if entry is not None and entry.shares.parent and account_entry is not None: | |
| break | |
| assert entry.shares.parent, "shares of a parent assoc must render parent" | |
| assert not entry.shares.set, "parent assoc must not report a share count" | |
| # The numeric account in the same response is the control: a dump stuck on | |
| # parent would satisfy the user assertions above on its own. | |
| assert account_entry.shares.set, "numeric assoc must report set" | |
| assert not account_entry.shares.parent, "numeric assoc must not render parent" | |
| assert account_entry.shares.number == 7, "numeric assoc must report its shares" | |
| def test_db_qos(slurmdb, create_coords, admin_level): | |
| from openapi_client.models.v0046_float64_no_val_struct import ( | |
| V0046Float64NoValStruct as V0046Float64NoVal, | |
| ) | |
| from openapi_client.models.v0046_openapi_slurmdbd_qos_resp import ( | |
| V0046OpenapiSlurmdbdQosResp, | |
| ) | |
| from openapi_client.models.v0046_qos import V0046Qos | |
| from openapi_client.models.v0046_tres import V0046Tres | |
| from openapi_client.models.v0046_uint32_no_val_struct import ( | |
| V0046Uint32NoValStruct as V0046Uint32NoVal, | |
| ) | |
| qos = V0046OpenapiSlurmdbdQosResp( | |
| qos=[ | |
| V0046Qos( | |
| description="test QOS", | |
| flags=[ | |
| "PARTITION_MAXIMUM_NODE", | |
| "PARTITION_TIME_LIMIT", | |
| "ENFORCE_USAGE_THRESHOLD", | |
| "NO_RESERVE", | |
| "DENY_LIMIT", | |
| "OVERRIDE_PARTITION_QOS", | |
| "NO_DECAY", | |
| ], | |
| limits=dict( | |
| min=dict( | |
| tres=dict( | |
| per=dict( | |
| job=[ | |
| V0046Tres( | |
| type="cpu", | |
| count=100, | |
| ), | |
| V0046Tres( | |
| type="memory", | |
| count=100000, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| name=qos_name, | |
| preempt=dict( | |
| exempt_time=V0046Uint32NoVal(set=True, number=199), | |
| ), | |
| priority=V0046Uint32NoVal(number=180, set=True), | |
| usage_factor=V0046Float64NoVal( | |
| set=True, | |
| number=82382.23823, | |
| ), | |
| usage_threshold=V0046Float64NoVal( | |
| set=True, | |
| number=929392.33, | |
| ), | |
| ), | |
| V0046Qos( | |
| description="test QOS 2", | |
| name=qos2_name, | |
| ), | |
| ] | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_qos(v0046_openapi_slurmdbd_qos_resp=qos) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_qos() | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.qos | |
| resp = slurmdb.slurmdb_v0046_get_single_qos(qos_name) | |
| assert not resp.warnings | |
| assert len(resp.errors) == 0 | |
| assert resp.qos | |
| for qos in resp.qos: | |
| assert qos.description == "test QOS" | |
| assert qos.flags | |
| for flag in qos.flags: | |
| assert flag in [ | |
| "PARTITION_MAXIMUM_NODE", | |
| "PARTITION_TIME_LIMIT", | |
| "ENFORCE_USAGE_THRESHOLD", | |
| "NO_RESERVE", | |
| "DENY_LIMIT", | |
| "OVERRIDE_PARTITION_QOS", | |
| "NO_DECAY", | |
| ] | |
| assert qos.limits.min.tres.per.job | |
| for tres in qos.limits.min.tres.per.job: | |
| assert tres.type == "cpu" or tres.type == "memory" | |
| if tres.type == "cpu": | |
| assert tres.count == 100 | |
| if tres.type == "memory": | |
| assert tres.count == 100000 | |
| assert qos.name == qos_name | |
| assert qos.preempt.exempt_time.set | |
| assert qos.preempt.exempt_time.number == 199 | |
| assert qos.priority.set | |
| assert qos.priority.number == 180 | |
| assert qos.usage_factor.set | |
| assert qos.usage_factor.number == 82382.23823 | |
| assert qos.usage_threshold.set | |
| assert qos.usage_threshold.number == 929392.33 | |
| resp = slurmdb.slurmdb_v0046_get_single_qos(qos2_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.qos | |
| for qos in resp.qos: | |
| assert qos.description == "test QOS 2" | |
| assert not qos.flags | |
| assert not qos.limits.min.tres.per.job | |
| assert qos.name == qos2_name | |
| assert not qos.preempt.exempt_time.set | |
| assert qos.priority.set | |
| assert qos.priority.number == 0 | |
| assert qos.usage_factor.set | |
| assert qos.usage_factor.number == 1 | |
| assert not qos.usage_threshold.set | |
| resp = slurmdb.slurmdb_v0046_delete_single_qos(qos_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_single_qos(qos_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.qos | |
| resp = slurmdb.slurmdb_v0046_delete_single_qos(qos2_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_single_qos(qos2_name) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert not resp.qos | |
| def test_db_tres(slurmdb): | |
| resp = slurmdb.slurmdb_v0046_get_tres() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| def test_db_config(slurmdb, admin_level): | |
| resp = slurmdb.slurmdb_v0046_get_config() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| def test_accounts_association_idempotent(slurmdb, admin_level, assoc_account): | |
| """POST /slurmdb/v0.0.46/accounts_association/ returns 200 with warnings (not 304) when account/association already exists.""" | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_account_short import V0046AccountShort | |
| from openapi_client.models.v0046_accounts_add_cond import V0046AccountsAddCond | |
| from openapi_client.models.v0046_openapi_accounts_add_cond_resp import ( | |
| V0046OpenapiAccountsAddCondResp, | |
| ) | |
| acct = assoc_account | |
| req = V0046OpenapiAccountsAddCondResp( | |
| association_condition=V0046AccountsAddCond( | |
| accounts=[acct], | |
| clusters=[local_cluster_name], | |
| ), | |
| account=V0046AccountShort( | |
| description="test account for idempotent POST", | |
| organization="test", | |
| ), | |
| ) | |
| # A /slurmdb/ POST that changes nothing answers HTTP 200 with a warning | |
| # stating that nothing changed. | |
| no_change = "reports nothing changed" | |
| warn_source = "slurmdb_accounts_add_cond" | |
| resp = slurmdb.slurmdb_v0046_post_accounts_association_with_http_info( | |
| v0046_openapi_accounts_add_cond_resp=req | |
| ) | |
| assert resp.status_code == 200, f"initial POST failed: {resp.status_code}" | |
| assert resp.data.errors == [], f"unexpected errors: {resp.data.errors}" | |
| assert ( | |
| acct in resp.data.added_accounts | |
| ), f"account not reported as added: {resp.data.added_accounts!r}" | |
| assert not any( | |
| no_change in w.description for w in resp.data.warnings | |
| ), f"initial POST unexpectedly reported no change: {resp.data.warnings}" | |
| before_assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=acct | |
| ) | |
| assert before_assocs.errors == [], f"unexpected errors: {before_assocs.errors}" | |
| assert ( | |
| len(before_assocs.associations) == 1 | |
| ), f"expected one association, got {len(before_assocs.associations)}" | |
| before_acct = slurmdb.slurmdb_v0046_get_account(acct) | |
| assert before_acct.errors == [], f"unexpected errors: {before_acct.errors}" | |
| assert ( | |
| len(before_acct.accounts) == 1 | |
| ), f"expected one account: {before_acct.accounts}" | |
| # The generated client raises ApiException for any non-2xx status. | |
| try: | |
| resp = slurmdb.slurmdb_v0046_post_accounts_association_with_http_info( | |
| v0046_openapi_accounts_add_cond_resp=req | |
| ) | |
| except ApiException as e: | |
| pytest.fail(f"idempotent POST raised HTTP {e.status}, expected 200") | |
| assert resp.status_code == 200, f"idempotent POST: {resp.status_code}" | |
| assert ( | |
| resp.data.errors == [] | |
| ), f"unexpected errors on idempotent POST: {resp.data.errors}" | |
| assert f"Already existing account {acct}" in ( | |
| resp.data.added_accounts or "" | |
| ), f"expected an already-exists report: {resp.data.added_accounts!r}" | |
| assert resp.data.warnings, "expected the no-op POST to report a warning" | |
| assert any( | |
| w.source == warn_source and no_change in w.description | |
| for w in resp.data.warnings | |
| ), f"expected a {no_change!r} warning from {warn_source}: {resp.data.warnings}" | |
| after_assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=acct | |
| ) | |
| assert after_assocs.errors == [], f"unexpected errors: {after_assocs.errors}" | |
| assert ( | |
| after_assocs.associations == before_assocs.associations | |
| ), f"associations changed: {after_assocs.associations}" | |
| after_acct = slurmdb.slurmdb_v0046_get_account(acct) | |
| assert after_acct.errors == [], f"unexpected errors: {after_acct.errors}" | |
| assert ( | |
| after_acct.accounts == before_acct.accounts | |
| ), f"account changed: {after_acct.accounts}" | |
| def test_users_association_idempotent(slurmdb, admin_level, assoc_user_account): | |
| """POST /slurmdb/v0.0.46/users_association/ returns 200 with warnings (not 304) when user/association already exists.""" | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_openapi_users_add_cond_resp import ( | |
| V0046OpenapiUsersAddCondResp, | |
| ) | |
| from openapi_client.models.v0046_user_short import V0046UserShort | |
| from openapi_client.models.v0046_users_add_cond import V0046UsersAddCond | |
| user, acct = assoc_user_account | |
| req = V0046OpenapiUsersAddCondResp( | |
| association_condition=V0046UsersAddCond( | |
| users=[user], | |
| accounts=[acct], | |
| clusters=[local_cluster_name], | |
| ), | |
| user=V0046UserShort(), | |
| ) | |
| # A /slurmdb/ POST that changes nothing answers HTTP 200 with a warning | |
| # stating that nothing changed. | |
| no_change = "reports nothing changed" | |
| warn_source = "slurmdb_users_add_cond" | |
| resp = slurmdb.slurmdb_v0046_post_users_association_with_http_info( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| assert resp.status_code == 200, f"initial POST failed: {resp.status_code}" | |
| assert resp.data.errors == [], f"unexpected errors: {resp.data.errors}" | |
| assert ( | |
| user in resp.data.added_users | |
| ), f"user not reported as added: {resp.data.added_users!r}" | |
| assert not any( | |
| no_change in w.description for w in resp.data.warnings | |
| ), f"initial POST unexpectedly reported no change: {resp.data.warnings}" | |
| before_assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=acct, user=user | |
| ) | |
| assert before_assocs.errors == [], f"unexpected errors: {before_assocs.errors}" | |
| assert ( | |
| len(before_assocs.associations) == 1 | |
| ), f"expected one association, got {len(before_assocs.associations)}" | |
| before_user = slurmdb.slurmdb_v0046_get_user(user) | |
| assert before_user.errors == [], f"unexpected errors: {before_user.errors}" | |
| assert len(before_user.users) == 1, f"expected one user: {before_user.users}" | |
| # The generated client raises ApiException for any non-2xx status. | |
| try: | |
| resp = slurmdb.slurmdb_v0046_post_users_association_with_http_info( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| except ApiException as e: | |
| pytest.fail(f"idempotent POST raised HTTP {e.status}, expected 200") | |
| assert resp.status_code == 200, f"idempotent POST: {resp.status_code}" | |
| assert ( | |
| resp.data.errors == [] | |
| ), f"unexpected errors on idempotent POST: {resp.data.errors}" | |
| assert user not in ( | |
| resp.data.added_users or "" | |
| ), f"idempotent POST added something: {resp.data.added_users!r}" | |
| assert resp.data.warnings, "expected the no-op POST to report a warning" | |
| assert any( | |
| w.source == warn_source and no_change in w.description | |
| for w in resp.data.warnings | |
| ), f"expected a {no_change!r} warning from {warn_source}: {resp.data.warnings}" | |
| after_assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=acct, user=user | |
| ) | |
| assert after_assocs.errors == [], f"unexpected errors: {after_assocs.errors}" | |
| assert ( | |
| after_assocs.associations == before_assocs.associations | |
| ), f"associations changed: {after_assocs.associations}" | |
| after_user = slurmdb.slurmdb_v0046_get_user(user) | |
| assert after_user.errors == [], f"unexpected errors: {after_user.errors}" | |
| assert after_user.users == before_user.users, f"user changed: {after_user.users}" | |
| def test_users_association_account_not_on_cluster( | |
| slurmdb, admin_level, assoc_user_account, assoc_other_cluster | |
| ): | |
| """POST /slurmdb/v0.0.46/users_association/ skips an account with no association on the target cluster and returns HTTP 200, as sacctmgr add user does.""" | |
| from openapi_client.models.v0046_openapi_users_add_cond_resp import ( | |
| V0046OpenapiUsersAddCondResp, | |
| ) | |
| from openapi_client.models.v0046_user_short import V0046UserShort | |
| from openapi_client.models.v0046_users_add_cond import V0046UsersAddCond | |
| user, acct = assoc_user_account | |
| other_cluster = assoc_other_cluster | |
| req = V0046OpenapiUsersAddCondResp( | |
| association_condition=V0046UsersAddCond( | |
| users=[user], | |
| accounts=[acct], | |
| clusters=[other_cluster], | |
| ), | |
| user=V0046UserShort(), | |
| ) | |
| # An account with no association on a requested cluster is skipped and | |
| # reported in the response body; a POST that adds nothing returns HTTP 200 | |
| # with a warning stating that nothing changed. | |
| resp = slurmdb.slurmdb_v0046_post_users_association_with_http_info( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| assert resp.status_code == 200, f"expected HTTP 200, got {resp.status_code}" | |
| assert not resp.data.errors, f"unexpected errors: {resp.data.errors}" | |
| assert any( | |
| "reports nothing changed" in w.description for w in resp.data.warnings | |
| ), f"expected a nothing-changed warning: {resp.data.warnings}" | |
| assert f"No account {acct} on cluster {other_cluster}, skipping." in ( | |
| resp.data.added_users or "" | |
| ), f"expected the skipped account to be reported: {resp.data.added_users!r}" | |
| # No association should have been created. | |
| assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=other_cluster, account=acct, user=user | |
| ) | |
| assert ( | |
| len(assocs.associations) == 0 | |
| ), f"association created despite account not on cluster: {assocs.associations}" | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show user {user} format=user", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"user {user} created although nothing was added" | |
| def test_users_association_clusters_omitted( | |
| slurmdb, admin_level, assoc_user_account, assoc_other_cluster | |
| ): | |
| """POST /slurmdb/v0.0.46/users_association/ with no clusters adds the user only where the account has an association and returns HTTP 200.""" | |
| from openapi_client.models.v0046_openapi_users_add_cond_resp import ( | |
| V0046OpenapiUsersAddCondResp, | |
| ) | |
| from openapi_client.models.v0046_user_short import V0046UserShort | |
| from openapi_client.models.v0046_users_add_cond import V0046UsersAddCond | |
| user, acct = assoc_user_account | |
| other_cluster = assoc_other_cluster | |
| req = V0046OpenapiUsersAddCondResp( | |
| association_condition=V0046UsersAddCond(users=[user], accounts=[acct]), | |
| user=V0046UserShort(), | |
| ) | |
| # With no clusters listed, clusters where the account has no association are | |
| # skipped and the request returns HTTP 200. | |
| resp = slurmdb.slurmdb_v0046_post_users_association_with_http_info( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| assert resp.status_code == 200, f"expected HTTP 200, got {resp.status_code}" | |
| assert not resp.data.errors, f"unexpected errors: {resp.data.errors}" | |
| assert f"No account {acct} on cluster {other_cluster}, skipping." in ( | |
| resp.data.added_users or "" | |
| ), f"expected {other_cluster} to be reported skipped: {resp.data.added_users!r}" | |
| assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=acct, user=user | |
| ) | |
| assert ( | |
| len(assocs.associations) == 1 | |
| ), f"expected an association on {local_cluster_name}: {assocs.associations}" | |
| assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=other_cluster, account=acct, user=user | |
| ) | |
| assert ( | |
| len(assocs.associations) == 0 | |
| ), f"association created on {other_cluster}: {assocs.associations}" | |
| def test_users_association_missing_account(slurmdb, admin_level, assoc_user): | |
| """POST /slurmdb/v0.0.46/users_association/ skips a nonexistent account and returns HTTP 200, as sacctmgr add user does.""" | |
| from openapi_client.models.v0046_openapi_users_add_cond_resp import ( | |
| V0046OpenapiUsersAddCondResp, | |
| ) | |
| from openapi_client.models.v0046_user_short import V0046UserShort | |
| from openapi_client.models.v0046_users_add_cond import V0046UsersAddCond | |
| user = assoc_user | |
| bad_acct = f"nonexistent-acct-{random.randrange(0, 99999999999)}" | |
| req = V0046OpenapiUsersAddCondResp( | |
| association_condition=V0046UsersAddCond( | |
| users=[user], | |
| accounts=[bad_acct], | |
| clusters=[local_cluster_name], | |
| ), | |
| user=V0046UserShort(), | |
| ) | |
| # A nonexistent account is skipped and reported in the response body; | |
| # a POST that adds nothing returns HTTP 200 with a warning stating that | |
| # nothing changed. | |
| resp = slurmdb.slurmdb_v0046_post_users_association_with_http_info( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| assert resp.status_code == 200, f"expected HTTP 200, got {resp.status_code}" | |
| assert not resp.data.errors, f"unexpected errors: {resp.data.errors}" | |
| assert any( | |
| "reports nothing changed" in w.description for w in resp.data.warnings | |
| ), f"expected a nothing-changed warning: {resp.data.warnings}" | |
| assert f"No account {bad_acct} on cluster {local_cluster_name}, skipping." in ( | |
| resp.data.added_users or "" | |
| ), f"expected the skipped account to be reported: {resp.data.added_users!r}" | |
| # Confirm no association was created for the nonexistent account. | |
| assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=bad_acct, user=user | |
| ) | |
| assert ( | |
| len(assocs.associations) == 0 | |
| ), f"association was created for nonexistent account: {assocs.associations}" | |
| assert not atf.run_command_output( | |
| f"sacctmgr -n -P show user {user} format=user", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ), f"user {user} created although nothing was added" | |
| def test_users_association_partial_invalid_account( | |
| slurmdb, admin_level, assoc_user_account | |
| ): | |
| """POST /slurmdb/v0.0.46/users_association/ adds the valid account and skips the invalid one, as sacctmgr add user does.""" | |
| from openapi_client.models.v0046_openapi_users_add_cond_resp import ( | |
| V0046OpenapiUsersAddCondResp, | |
| ) | |
| from openapi_client.models.v0046_user_short import V0046UserShort | |
| from openapi_client.models.v0046_users_add_cond import V0046UsersAddCond | |
| user, acct = assoc_user_account | |
| bad_acct = f"nonexistent-acct-{random.randrange(0, 99999999999)}" | |
| req = V0046OpenapiUsersAddCondResp( | |
| association_condition=V0046UsersAddCond( | |
| users=[user], | |
| accounts=[acct, bad_acct], | |
| clusters=[local_cluster_name], | |
| ), | |
| user=V0046UserShort(), | |
| ) | |
| # Accounts that cannot be added are skipped and reported in the response | |
| # body; the rest of the request is committed. | |
| resp = slurmdb.slurmdb_v0046_post_users_association_with_http_info( | |
| v0046_openapi_users_add_cond_resp=req | |
| ) | |
| assert resp.status_code == 200, f"expected HTTP 200, got {resp.status_code}" | |
| assert not resp.data.errors, f"unexpected errors: {resp.data.errors}" | |
| assert not any( | |
| "reports nothing changed" in w.description for w in resp.data.warnings | |
| ), f"partial add reported no change: {resp.data.warnings}" | |
| assert f"No account {bad_acct} on cluster {local_cluster_name}, skipping." in ( | |
| resp.data.added_users or "" | |
| ), f"expected the skipped account to be reported: {resp.data.added_users!r}" | |
| assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=acct, user=user | |
| ) | |
| assert ( | |
| len(assocs.associations) == 1 | |
| ), f"valid account not added: {assocs.associations}" | |
| assocs = slurmdb.slurmdb_v0046_get_associations( | |
| cluster=local_cluster_name, account=bad_acct, user=user | |
| ) | |
| assert ( | |
| len(assocs.associations) == 0 | |
| ), f"association created for nonexistent account: {assocs.associations}" | |
| @pytest.mark.parametrize( | |
| "entity,removed_field", | |
| [ | |
| ("account", "removed_accounts"), | |
| ("user", None), | |
| ("wckey", "deleted_wckeys"), | |
| ("qos", "removed_qos"), | |
| ("cluster", "deleted_clusters"), | |
| ("association", "removed_associations"), | |
| ("associations", "removed_associations"), | |
| ], | |
| ) | |
| def test_delete_nonexistent_returns_200(slurmdb, admin_level, entity, removed_field): | |
| """DELETE of a nonexistent entity returns HTTP 200 with a warning, not HTTP 304.""" | |
| from openapi_client.exceptions import ApiException | |
| bad = f"nonexistent-{random.randrange(0, 99999999999)}" | |
| deletes = { | |
| "account": lambda: slurmdb.slurmdb_v0046_delete_account_with_http_info(bad), | |
| "user": lambda: slurmdb.slurmdb_v0046_delete_user_with_http_info(bad), | |
| "wckey": lambda: slurmdb.slurmdb_v0046_delete_wckey_with_http_info(bad), | |
| "qos": lambda: slurmdb.slurmdb_v0046_delete_single_qos_with_http_info(bad), | |
| "cluster": lambda: slurmdb.slurmdb_v0046_delete_cluster_with_http_info(bad), | |
| "association": lambda: slurmdb.slurmdb_v0046_delete_association_with_http_info( | |
| cluster=local_cluster_name, account=bad, user=bad | |
| ), | |
| "associations": lambda: ( | |
| slurmdb.slurmdb_v0046_delete_associations_with_http_info( | |
| cluster=local_cluster_name, account=bad, user=bad | |
| ) | |
| ), | |
| } | |
| # The generated client raises ApiException for any non-2xx status. | |
| try: | |
| resp = deletes[entity]() | |
| except ApiException as e: | |
| pytest.fail(f"DELETE {entity}: raised HTTP {e.status}, expected 200") | |
| assert resp.status_code == 200, f"DELETE {entity}: {resp.status_code}" | |
| assert ( | |
| resp.data.errors == [] | |
| ), f"DELETE {entity}: unexpected errors: {resp.data.errors}" | |
| assert any( | |
| "found nothing" in w.description for w in resp.data.warnings | |
| ), f"DELETE {entity}: expected a 'found nothing' warning: {resp.data.warnings}" | |
| # The removed-entity list is required on a DELETE 200 response, so it must | |
| # be present and empty rather than omitted when nothing matched. | |
| if removed_field is not None: | |
| assert ( | |
| getattr(resp.data, removed_field) == [] | |
| ), f"DELETE {entity}: expected an empty {removed_field}" | |
| @pytest.mark.parametrize("case", ["nonexistent-job-list", "nonexistent-job-single"]) | |
| def test_jobs_post_no_change_returns_200(slurmdb, admin_level, case): | |
| """POST of a slurmdb job modify that matches no job returns HTTP 200 with a warning, not HTTP 304.""" | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_job_modify import V0046JobModify | |
| from openapi_client.models.v0046_openapi_job_modify_req import ( | |
| V0046OpenapiJobModifyReq, | |
| ) | |
| calls = { | |
| "nonexistent-job-list": lambda: slurmdb.slurmdb_v0046_post_jobs_with_http_info( | |
| v0046_openapi_job_modify_req=V0046OpenapiJobModifyReq( | |
| job_id_list=[NONEXISTENT_JOB_ID], | |
| job_rec=V0046JobModify(extra="no-change-probe"), | |
| ) | |
| ), | |
| "nonexistent-job-single": lambda: slurmdb.slurmdb_v0046_post_job_with_http_info( | |
| NONEXISTENT_JOB_ID, v0046_job_modify=V0046JobModify(extra="no-change-probe") | |
| ), | |
| } | |
| # The generated client raises ApiException for any non-2xx status. | |
| try: | |
| resp = calls[case]() | |
| except ApiException as e: | |
| pytest.fail(f"POST jobs ({case}): raised HTTP {e.status}, expected 200") | |
| assert resp.status_code == 200, f"POST jobs ({case}): {resp.status_code}" | |
| assert ( | |
| resp.data.errors == [] | |
| ), f"POST jobs ({case}): unexpected errors: {resp.data.errors}" | |
| assert any( | |
| "reports nothing changed" in w.description for w in resp.data.warnings | |
| ), f"POST jobs ({case}): expected a 'reports nothing changed' warning: {resp.data.warnings}" | |
| assert any( | |
| "found nothing" in w.description for w in resp.data.warnings | |
| ), f"POST jobs ({case}): expected a 'found nothing' warning: {resp.data.warnings}" | |
| def test_jobs(slurm, slurmdb, non_admin): | |
| from openapi_client.models.v0046_job_comment import V0046JobComment | |
| from openapi_client.models.v0046_job_desc_msg import V0046JobDescMsg | |
| from openapi_client.models.v0046_job_info import V0046JobInfo | |
| from openapi_client.models.v0046_job_modify import V0046JobModify | |
| from openapi_client.models.v0046_job_modify_tres import V0046JobModifyTres | |
| from openapi_client.models.v0046_job_submit_req import V0046JobSubmitReq | |
| from openapi_client.models.v0046_openapi_job_modify_req import ( | |
| V0046OpenapiJobModifyReq, | |
| ) | |
| from openapi_client.models.v0046_process_exit_code_verbose import ( | |
| V0046ProcessExitCodeVerbose, | |
| ) | |
| from openapi_client.models.v0046_process_exit_code_verbose_signal import ( | |
| V0046ProcessExitCodeVerboseSignal, | |
| ) | |
| from openapi_client.models.v0046_tres import V0046Tres | |
| from openapi_client.models.v0046_uint32_no_val_struct import V0046Uint32NoValStruct | |
| from openapi_client.models.v0046_uint32_no_val_struct import ( | |
| V0046Uint32NoValStruct as V0046Uint32NoVal, | |
| ) | |
| script = "#!/bin/bash\n/bin/true" | |
| env = ["PATH=/bin/:/sbin/:/usr/bin/:/usr/sbin/"] | |
| layout = "tpu7x=4x4x4,foo=bar" | |
| job = V0046JobSubmitReq( | |
| script=script, | |
| job=V0046JobDescMsg( | |
| partition=partition_name, | |
| name="runjob", | |
| environment=env, | |
| current_working_directory="/tmp/", | |
| layout=layout, | |
| ), | |
| ) | |
| resp = slurm.slurm_v0046_post_job_submit(job) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.job_id | |
| assert resp.step_id | |
| jobid1 = int(resp.job_id) | |
| resp = slurm.slurm_v0046_get_jobs() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| for job in resp.jobs: | |
| assert job.job_id == jobid1 | |
| assert job.name == "runjob" | |
| assert job.partition == partition_name | |
| # slurm_rc is the job's Slurm error code. Slurm did not fail this job, so | |
| # it is 0. | |
| assert ( | |
| job.slurm_rc == 0 | |
| ), f"Job {job.job_id} was not failed by Slurm, so its slurm_rc should be 0" | |
| resp = slurm.slurm_v0046_get_job(str(jobid1)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| for job in resp.jobs: | |
| assert job.job_id == jobid1 | |
| assert job.name == "runjob" | |
| assert job.partition == partition_name | |
| assert job.layout == layout, f"slurmctld should return layout {layout}" | |
| job = V0046JobSubmitReq( | |
| script=script, | |
| job=V0046JobDescMsg( | |
| partition=partition_name, | |
| name="runjob", | |
| environment=env, | |
| current_working_directory="/tmp/", | |
| ), | |
| ) | |
| resp = slurm.slurm_v0046_post_job_submit(job) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.job_id | |
| assert resp.step_id | |
| jobid2 = int(resp.job_id) | |
| resp = slurm.slurm_v0046_get_job(str(jobid2)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert len(resp.jobs) == 1 | |
| for job in resp.jobs: | |
| assert job.job_id == jobid2 | |
| assert job.name == "runjob" | |
| assert job.partition == partition_name | |
| # submit a HELD job to be able to update it | |
| job = V0046JobSubmitReq( | |
| script=script, | |
| job=V0046JobDescMsg( | |
| partition=partition_name, | |
| name="test job", | |
| environment=env, | |
| priority=V0046Uint32NoVal(number=0, set=True), | |
| current_working_directory="/tmp/", | |
| ), | |
| ) | |
| resp = slurm.slurm_v0046_post_job_submit(job) | |
| assert len(resp.warnings) > 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.job_id | |
| assert resp.step_id | |
| jobid3 = int(resp.job_id) | |
| job = V0046JobDescMsg( | |
| environment=env, | |
| partition=partition_name, | |
| name="updated test job", | |
| priority=V0046Uint32NoVal(number=0, set=True), | |
| ) | |
| resp = slurm.slurm_v0046_post_job(str(jobid3), v0046_job_desc_msg=job) | |
| assert not len(resp.warnings) | |
| assert not len(resp.errors) | |
| if resp.results: | |
| for result in resp.results: | |
| assert result.job_id == jobid3 | |
| assert result.error_code == 0 | |
| resp = slurm.slurm_v0046_get_job(str(jobid3)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| for job in resp.jobs: | |
| assert job.job_id == jobid3 | |
| assert job.name == "updated test job" | |
| assert job.partition == partition_name | |
| assert job.priority.set | |
| assert job.priority.number == 0 | |
| assert job.user_name == local_user_name | |
| resp = slurm.slurm_v0046_delete_job(str(jobid3)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurm.slurm_v0046_get_job(str(jobid3)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| for job in resp.jobs: | |
| assert job.job_id == jobid3 | |
| assert job.name == "updated test job" | |
| assert job.partition == partition_name | |
| assert job.user_name == local_user_name | |
| assert job.job_state == ["CANCELLED"] | |
| # Ensure that job is in the DB before querying it | |
| atf.wait_for_job_accounted(jobid3, fatal=True) | |
| resp = slurmdb.slurmdb_v0046_get_jobs() | |
| assert len(resp.errors) == 0 | |
| for t in atf.timer(): | |
| resp = slurmdb.slurmdb_v0046_get_job(str(jobid3)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.jobs | |
| updated_job_found = False | |
| for job in resp.jobs: | |
| if job.name == "updated test job": | |
| assert job.job_id == jobid3 | |
| assert job.name == "updated test job" | |
| assert job.partition == partition_name | |
| updated_job_found = True | |
| break | |
| if updated_job_found: | |
| break | |
| else: | |
| assert False, "Updated job should reach the DB" | |
| resp = slurmdb.slurmdb_v0046_get_jobs(users=local_user_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.jobs | |
| for job in resp.jobs: | |
| assert job.user == local_user_name | |
| # Update job in db -- posting to /job/jobid/ | |
| atf.wait_for_job_accounted(jobid1, "State", "COMPLETED", fatal=True) | |
| atf.wait_for_job_accounted(jobid2, "State", "COMPLETED", fatal=True) | |
| resp = slurmdb.slurmdb_v0046_get_job(str(jobid1)) | |
| assert len(resp.warnings) == 0, f"Unexpected warnings: {resp.warnings}" | |
| assert len(resp.errors) == 0, f"Unexpected errors: {resp.errors}" | |
| assert len(resp.jobs) == 1, f"Expected job {jobid1} in the DB" | |
| assert resp.jobs[0].layout == layout, f"slurmdbd should return layout {layout}" | |
| atf.run_command( | |
| f"sacctmgr -i mod user {local_cluster_name} set AdminLevel=Admin", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| WIFEXIT_CODE = V0046Uint32NoValStruct( | |
| set=True, infinite=False, number=4 # 1024 >> 8 | |
| ) | |
| job_modify = V0046JobModify( | |
| comment=V0046JobComment( | |
| administrator="admin1", | |
| job="job1", | |
| system="system1", | |
| ), | |
| derived_exit_code=V0046ProcessExitCodeVerbose( | |
| return_code=V0046Uint32NoValStruct(set=True, infinite=False, number=1024), | |
| ), | |
| extra="extra1", | |
| tres=V0046JobModifyTres( | |
| allocated=[V0046Tres(type="energy", count=12346, id=3)] | |
| ), | |
| wckey="mywckey1", | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_job(str(jobid1), v0046_job_modify=job_modify) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_jobs(step=str(jobid1)) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert len(resp.jobs) == 1 | |
| for job in resp.jobs: | |
| logging.debug(job) | |
| assert job.comment == job_modify.comment | |
| assert job.derived_exit_code.return_code == WIFEXIT_CODE | |
| # The same on the slurmdb job model, a separate model behind a | |
| # separate endpoint. | |
| assert ( | |
| job.slurm_rc == 0 | |
| ), f"Job {job.job_id} was not failed by Slurm, so its slurm_rc should be 0" | |
| assert job.extra == job_modify.extra | |
| found_tres = False | |
| for tres in job.tres.allocated: | |
| if tres.type == "energy": | |
| found_tres = True | |
| tres == job_modify.tres.allocated[0] | |
| assert found_tres | |
| assert job.wckey.wckey == job_modify.wckey | |
| # Update jobs in db -- posting to /jobs/ | |
| # Update values to be different | |
| WIFEXIT_CODE = V0046Uint32NoValStruct( | |
| set=True, infinite=False, number=8 # 2048 >> 8 | |
| ) | |
| job_modify = V0046JobModify( | |
| comment=V0046JobComment( | |
| administrator="admin2", | |
| job="job2", | |
| system="system2", | |
| ), | |
| derived_exit_code=V0046ProcessExitCodeVerbose( | |
| return_code=V0046Uint32NoValStruct(set=True, infinite=False, number=2048), | |
| ), | |
| extra="extra2", | |
| tres=V0046JobModifyTres( | |
| allocated=[V0046Tres(type="energy", count=54321, id=3)] | |
| ), | |
| wckey="mywckey2", | |
| ) | |
| job_modify_req = V0046OpenapiJobModifyReq( | |
| job_id_list=[str(jobid1), str(jobid2)], job_rec=job_modify | |
| ) | |
| resp = slurmdb.slurmdb_v0046_post_jobs(v0046_openapi_job_modify_req=job_modify_req) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurmdb.slurmdb_v0046_get_jobs(job_name="runjob") | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert len(resp.jobs) == 2 | |
| for job in resp.jobs: | |
| logging.debug(job) | |
| assert job.comment == job_modify.comment | |
| assert job.derived_exit_code.return_code == WIFEXIT_CODE | |
| assert job.extra == job_modify.extra | |
| found_tres = False | |
| for tres in job.tres.allocated: | |
| if tres.type == "energy": | |
| found_tres = True | |
| tres == job_modify.tres.allocated[0] | |
| found_tres = True | |
| assert job.wckey.wckey == job_modify.wckey | |
| @pytest.fixture | |
| def runtime_job_hook(request): | |
| """Install an accepting or rejecting runtime hook for the job subtests.""" | |
| atf.require_build_config("HAVE_LUA", reason="Issue 50190: runtime/lua requires Lua") | |
| result = "ERROR" if request.param else "SUCCESS" | |
| atf.require_config_file( | |
| "runtime.lua", | |
| runtime_lua_script | |
| + f""" | |
| function slurm_runtime_apply_job_desc(id, task_id) | |
| io.stderr:write("{runtime_hook_marker}\\n") | |
| io.stderr:flush() | |
| return slurm.{result} | |
| end | |
| """, | |
| ) | |
| log_command = f"cat {shlex.quote(atf.properties['slurmrestd_log'].name)}" | |
| before = atf.run_command_output(log_command, fatal=True).count(runtime_hook_marker) | |
| yield request.param, log_command, before | |
| atf.require_config_file("runtime.lua", runtime_lua_script) | |
| @pytest.mark.skip(reason="Issue 50190: runtime/lua building is disabled") | |
| @pytest.mark.parametrize( | |
| "operation", ["submit", "het-submit", "allocate", "het-allocate"] | |
| ) | |
| @pytest.mark.parametrize( | |
| "runtime_job_hook", [False, True], indirect=True, ids=["accept", "reject"] | |
| ) | |
| def test_jobs_runtime_hook(operation, runtime_job_hook, non_admin): | |
| """Job submit and allocate endpoints run the runtime hook before their RPC.""" | |
| reject, log_command, calls_before = runtime_job_hook | |
| job = { | |
| "name": "rest_runtime_hook", | |
| "runtime": {"type": "lua"}, | |
| "environment": ["PATH=/usr/bin:/bin"], | |
| "current_working_directory": os.getcwd(), | |
| "minimum_nodes": 1, | |
| "maximum_nodes": 1, | |
| "tasks": 1, | |
| } | |
| path = "job/allocate" if "allocate" in operation else "job/submit" | |
| if "submit" in operation: | |
| job["hold"] = True | |
| if operation.startswith("het-"): | |
| # The Lua component comes second: checking only the first must fail. | |
| first = {**job, "runtime": {"type": "none"}} | |
| payload = {"hetjob" if "allocate" in operation else "jobs": [first, job]} | |
| else: | |
| payload = {"job": job} | |
| if "submit" in operation: | |
| payload["script"] = "#!/bin/sh\ntrue\n" | |
| before = set(atf.get_jobs(fatal=True)) | |
| response = requests.post( | |
| f"{atf.properties['slurmrestd_url'].rstrip('/')}/slurm/v0.0.46/{path}", | |
| headers=atf.properties["slurmrestd-headers"], | |
| json=payload, | |
| timeout=30, | |
| ) | |
| body = response.json() | |
| calls = ( | |
| atf.run_command_output(log_command, fatal=True).count(runtime_hook_marker) | |
| - calls_before | |
| ) | |
| assert calls == 1, f"{operation} must call the Lua hook once: {response.text}" | |
| if reject: | |
| assert ( | |
| not response.ok | |
| ), f"Rejected {operation} returned success: {response.text}" | |
| assert any( | |
| error["source"] == "runtime_g_cli_apply_job_desc()" | |
| for error in body["errors"] | |
| ), f"Expected runtime hook error: {response.text}" | |
| new_jobs = set(atf.get_jobs(fatal=True)) - before | |
| assert ( | |
| not new_jobs | |
| ), f"Rejected request must not create any jobs or heterogeneous components: {new_jobs}" | |
| else: | |
| assert response.ok and not body["errors"], response.text | |
| assert body["job_id"], f"Successful {operation} returned no job ID: {body}" | |
| @pytest.mark.parametrize("runtime_job_hook", [False], indirect=True) | |
| def test_jobs_update_runtime(runtime_job_hook, non_admin): | |
| """A job update that sets a runtime is rejected without calling its hook.""" | |
| _, log_command, calls_before = runtime_job_hook | |
| job_id = atf.submit_job_sbatch("--hold --comment=original --wrap=true", fatal=True) | |
| response = requests.post( | |
| f"{atf.properties['slurmrestd_url'].rstrip('/')}/slurm/v0.0.46/job/{job_id}", | |
| headers=atf.properties["slurmrestd-headers"], | |
| json={"runtime": {"type": "lua"}, "comment": "updated"}, | |
| timeout=30, | |
| ) | |
| body = response.json() | |
| assert not response.ok, f"Runtime update was accepted: {response.text}" | |
| assert any( | |
| "runtime" in error["description"] for error in body["errors"] | |
| ), f"Expected runtime update error: {response.text}" | |
| calls = ( | |
| atf.run_command_output(log_command, fatal=True).count(runtime_hook_marker) | |
| - calls_before | |
| ) | |
| assert calls == 0, "A job update must not call the runtime hook" | |
| assert ( | |
| atf.get_job_parameter(job_id, "Comment", fatal=True) == "original" | |
| ), "A rejected runtime update must not change the job" | |
| @pytest.mark.parametrize("runtime_job_hook", [True], indirect=True) | |
| def test_jobs_unknown_runtime(runtime_job_hook): | |
| """A missing plugin returns an API error without submitting a job.""" | |
| _, log_command, calls_before = runtime_job_hook | |
| before = set(atf.get_jobs(fatal=True)) | |
| response = requests.post( | |
| f"{atf.properties['slurmrestd_url'].rstrip('/')}/slurm/v0.0.46/job/submit", | |
| headers=atf.properties["slurmrestd-headers"], | |
| json={ | |
| "script": "#!/bin/sh\ntrue\n", | |
| "job": {"runtime": {"type": "nonexistent_50190"}, "hold": True}, | |
| }, | |
| timeout=30, | |
| ) | |
| body = response.json() | |
| assert not response.ok, f"Unknown runtime was accepted: {response.text}" | |
| assert any( | |
| error["source"] == "runtime_g_init()" for error in body["errors"] | |
| ), f"Expected runtime initialization error: {response.text}" | |
| calls = ( | |
| atf.run_command_output(log_command, fatal=True).count(runtime_hook_marker) | |
| - calls_before | |
| ) | |
| assert calls == 0, "An unknown runtime must not invoke another plugin's hook" | |
| new_jobs = set(atf.get_jobs(fatal=True)) - before | |
| assert not new_jobs, f"Unknown runtime created jobs: {new_jobs}" | |
| def _assert_invalid_account_rejection(exc): | |
| """Check a rejected job submission against the documented response.""" | |
| resp = json.loads(exc.body) | |
| assert resp["errors"], f"Expected errors in the response, got: {resp}" | |
| assert any( | |
| "invalid account" in err["error"].lower() for err in resp["errors"] | |
| ), f"Expected an Invalid account error, got: {resp['errors']}" | |
| assert resp.get("job_id") is None, f"Rejected job got a job_id: {resp}" | |
| def test_jobs_associated_account( | |
| slurm, association_enforcement, create_local_user_assoc | |
| ): | |
| """Test that job submission with an associated account is accepted. | |
| Verifies that when AccountingStorageEnforce=associations is set, | |
| submitting a job with an account the user is associated with succeeds. | |
| """ | |
| from openapi_client.models.v0046_job_desc_msg import V0046JobDescMsg | |
| from openapi_client.models.v0046_job_submit_req import V0046JobSubmitReq | |
| script = "#!/bin/bash\n/bin/true" | |
| env = ["PATH=/bin/:/sbin/:/usr/bin/:/usr/sbin/"] | |
| job = V0046JobSubmitReq( | |
| script=script, | |
| job=V0046JobDescMsg( | |
| account=account_name, | |
| partition=partition_name, | |
| name="test-associated-account", | |
| environment=env, | |
| current_working_directory="/tmp/", | |
| ), | |
| ) | |
| resp = slurm.slurm_v0046_post_job_submit(job) | |
| assert len(resp.errors) == 0, f"Expected no errors, got: {resp.errors}" | |
| assert resp.job_id, f"Expected a job_id for an associated account: {resp}" | |
| # The account and association can't be removed while the job is active. | |
| atf.wait_for_job_accounted(resp.job_id, "End", r"^\d", fatal=True) | |
| @pytest.mark.parametrize( | |
| "invalid_account", | |
| ["totally_invalid_account_name", account2_name], | |
| ids=["nonexistent", "not_associated"], | |
| ) | |
| def test_jobs_invalid_account( | |
| slurm, association_enforcement, create_local_user_assoc, invalid_account | |
| ): | |
| """Test that job submission with invalid account is rejected. | |
| Verifies that when AccountingStorageEnforce=associations is set, | |
| submitting a job with an account that does not exist, or that exists | |
| but the user is not associated with, is properly rejected. | |
| """ | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_job_desc_msg import V0046JobDescMsg | |
| from openapi_client.models.v0046_job_submit_req import V0046JobSubmitReq | |
| script = "#!/bin/bash\n/bin/true" | |
| env = ["PATH=/bin/:/sbin/:/usr/bin/:/usr/sbin/"] | |
| # Test that job submission with invalid account is rejected | |
| invalid_job = V0046JobSubmitReq( | |
| script=script, | |
| job=V0046JobDescMsg( | |
| account=invalid_account, | |
| partition=partition_name, | |
| name="test-invalid-account", | |
| environment=env, | |
| current_working_directory="/tmp/", | |
| ), | |
| ) | |
| with pytest.raises(ApiException) as exc_info: | |
| slurm.slurm_v0046_post_job_submit(invalid_job) | |
| _assert_invalid_account_rejection(exc_info.value) | |
| def test_jobs_missing_user_association( | |
| slurm, association_enforcement, no_local_user_assoc | |
| ): | |
| """Test that job submission by user with no associations is rejected. | |
| Verifies that when AccountingStorageEnforce=associations is set, | |
| a user without any associations cannot submit jobs. | |
| """ | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_job_desc_msg import V0046JobDescMsg | |
| from openapi_client.models.v0046_job_submit_req import V0046JobSubmitReq | |
| script = "#!/bin/bash\n/bin/true" | |
| env = ["PATH=/bin/:/sbin/:/usr/bin/:/usr/sbin/"] | |
| # Test that job submission without user associations is rejected | |
| # Don't specify account or user - job will run as local_user_name with no associations | |
| job = V0046JobSubmitReq( | |
| script=script, | |
| job=V0046JobDescMsg( | |
| partition=partition_name, | |
| name="test-no-association", | |
| environment=env, | |
| current_working_directory="/tmp/", | |
| ), | |
| ) | |
| # A user with no default account/association is rejected the same | |
| # way as an invalid account (ESLURM_INVALID_ACCOUNT). | |
| with pytest.raises(ApiException) as exc_info: | |
| slurm.slurm_v0046_post_job_submit(job) | |
| _assert_invalid_account_rejection(exc_info.value) | |
| @pytest.fixture(scope="function") | |
| def reservation(setup): | |
| # Ensure that ALL nodes are idle before reserving them (or scontrol may return an error) | |
| nodes = atf.get_nodes() | |
| for node in nodes: | |
| atf.wait_for_node_state(node, "IDLE", fatal=True) | |
| atf.run_command( | |
| f"scontrol create reservation starttime=now duration=120 user=root nodes=ALL ReservationName={resv_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| yield | |
| atf.run_command( | |
| f"scontrol delete ReservationName={resv_name}", | |
| user=atf.properties["slurm-user"], | |
| fatal=False, | |
| ) | |
| def test_jobs_post_empty_modify_returns_200(slurmdb, admin_level): | |
| """POST of a slurmdb job modify with no modifiable field returns HTTP 200 with a warning, not HTTP 304.""" | |
| # Runs after test_jobs, which expects slurmctld to list only its own jobs. | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_job_modify import V0046JobModify | |
| from openapi_client.models.v0046_openapi_job_modify_req import ( | |
| V0046OpenapiJobModifyReq, | |
| ) | |
| job_id = atf.submit_job_sbatch('--wrap "true"', fatal=True) | |
| for _ in atf.timer(fatal=True): | |
| if atf.run_command_output(f"sacct -XPnj {job_id} -o JobID", fatal=True): | |
| break | |
| try: | |
| resp = slurmdb.slurmdb_v0046_post_jobs_with_http_info( | |
| v0046_openapi_job_modify_req=V0046OpenapiJobModifyReq( | |
| job_id_list=[str(job_id)], job_rec=V0046JobModify() | |
| ) | |
| ) | |
| except ApiException as e: | |
| pytest.fail( | |
| f"empty modify of job {job_id}: raised HTTP {e.status}, expected 200" | |
| ) | |
| assert resp.status_code == 200, f"empty modify of job {job_id}: {resp.status_code}" | |
| assert ( | |
| resp.data.errors == [] | |
| ), f"empty modify of job {job_id}: unexpected errors: {resp.data.errors}" | |
| assert any( | |
| "reports nothing changed" in w.description for w in resp.data.warnings | |
| ), f"empty modify of job {job_id}: expected a no-change warning: {resp.data.warnings}" | |
| def test_partitions(slurm): | |
| resp = slurm.slurm_v0046_get_partition(partition_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.partitions | |
| for part in resp.partitions: | |
| assert part.name == partition_name | |
| resp = slurm.slurm_v0046_get_partitions() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.partitions | |
| def test_partition_get_post_roundtrip(slurm, admin_level): | |
| """GET one partition, POST the same partition descriptions as an update. | |
| Echoing read-only fields (cluster, totals, tres, etc.) may produce warnings | |
| but must not produce errors. | |
| """ | |
| from openapi_client.models.v0046_openapi_partitions_mod_req import ( | |
| V0046OpenapiPartitionsModReq, | |
| ) | |
| get_resp = slurm.slurm_v0046_get_partition(partition_name) | |
| assert len(get_resp.errors) == 0, "GET partition should not return errors" | |
| assert len(get_resp.warnings) == 0, "GET partition should not return warnings" | |
| assert len(get_resp.partitions) == 1, "GET partition should return one partition" | |
| for part in get_resp.partitions: | |
| assert part.name == partition_name, "GET should return the requested partition" | |
| req = V0046OpenapiPartitionsModReq(partitions=get_resp.partitions) | |
| post_resp = slurm.slurm_v0046_post_partitions(req) | |
| assert len(post_resp.errors) == 0, "POST echoed partition must not return errors" | |
| def test_post_partitions(slurm, admin_level): | |
| from openapi_client.models.v0046_openapi_partitions_mod_req import ( | |
| V0046OpenapiPartitionsModReq, | |
| ) | |
| from openapi_client.models.v0046_partition_info import V0046PartitionInfo | |
| from openapi_client.models.v0046_partition_info_defaults import ( | |
| V0046PartitionInfoDefaults, | |
| ) | |
| from openapi_client.models.v0046_partition_info_nodes import V0046PartitionInfoNodes | |
| from openapi_client.models.v0046_partition_info_partition import ( | |
| V0046PartitionInfoPartition, | |
| ) | |
| from openapi_client.models.v0046_uint64_no_val_struct import V0046Uint64NoValStruct | |
| part_1_name = "part_1" | |
| part_2_name = "part_2" | |
| nodes = list(atf.get_nodes().keys()) | |
| part_1_nodes = atf.run_command_output( | |
| f"scontrol show hostlist {','.join(nodes[0:5])}" | |
| ).strip() | |
| part_2_nodes = atf.run_command_output( | |
| f"scontrol show hostlist {','.join(nodes[5:10])}" | |
| ).strip() | |
| req = V0046OpenapiPartitionsModReq( | |
| partitions=[ | |
| V0046PartitionInfo( | |
| name=part_1_name, | |
| nodes=V0046PartitionInfoNodes(configured=part_1_nodes), | |
| flags=[ | |
| "HIDDEN", | |
| "NO_ROOT", | |
| "ROOT_ONLY", | |
| "REQ_RESV", | |
| "LLN", | |
| "PDOI", | |
| ], | |
| preempt_mode=[ | |
| "REQUEUE", | |
| ], | |
| defaults=V0046PartitionInfoDefaults( | |
| partition_memory_per_cpu=V0046Uint64NoValStruct( | |
| set=True, | |
| number=10, | |
| ), | |
| ), | |
| partition=V0046PartitionInfoPartition( | |
| oversubscribe="FORCE:5", exclusive="USER" | |
| ), | |
| ), | |
| V0046PartitionInfo( | |
| name=part_2_name, | |
| nodes=V0046PartitionInfoNodes(configured=part_2_nodes), | |
| flags=[ | |
| "LLN", | |
| ], | |
| preempt_mode=["DISABLED"], | |
| defaults=V0046PartitionInfoDefaults( | |
| partition_memory_per_node=V0046Uint64NoValStruct( | |
| set=True, | |
| number=12, | |
| ) | |
| ), | |
| partition=V0046PartitionInfoPartition( | |
| oversubscribe="YES:6", exclusive="NO" | |
| ), | |
| ), | |
| ] | |
| ) | |
| # Create partitions part_1 and part_2 | |
| resp = slurm.slurm_v0046_post_partitions(req) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| # Check that they were created | |
| resp = slurm.slurm_v0046_get_partitions() | |
| assert len(resp.errors) == 0 | |
| match_cnt = 0 | |
| inx = 0 | |
| for part in resp.partitions: | |
| if part.name == part_1_name: | |
| inx = 0 | |
| elif part.name == part_2_name: | |
| inx = 1 | |
| else: | |
| continue | |
| match_cnt += 1 | |
| assert ( | |
| req.partitions[inx].nodes.configured == part.nodes.configured | |
| ), "Partition was not created with the correct nodes" | |
| assert set(req.partitions[inx].flags) == set( | |
| part.flags | |
| ), "Partition was not create with the correct flags" | |
| assert set(req.partitions[inx].preempt_mode) == set( | |
| part.preempt_mode | |
| ), "Partition was not create with the correct PreemptMode" | |
| if req.partitions[inx].defaults.partition_memory_per_cpu: | |
| assert ( | |
| req.partitions[inx].defaults.partition_memory_per_cpu.number | |
| == part.defaults.partition_memory_per_cpu.number | |
| ), "Partition was not create with the correct DefMemPerCpu" | |
| if req.partitions[inx].defaults.partition_memory_per_node: | |
| assert ( | |
| req.partitions[inx].defaults.partition_memory_per_node.number | |
| == part.defaults.partition_memory_per_node.number | |
| ), "Partition was not create with the correct DefMemPerNode" | |
| assert req.partitions[inx].partition is not None | |
| assert part.partition is not None | |
| assert ( | |
| req.partitions[inx].partition.oversubscribe == part.partition.oversubscribe | |
| ), "Partition oversubscribe string did not round-trip" | |
| assert match_cnt == 2 | |
| # Test updating | |
| req.partitions[0].nodes.configured = part_2_nodes # swap nodes | |
| req.partitions[1].nodes.configured = part_1_nodes | |
| req.partitions[0].flags = [ # clear all flags | |
| "HIDDEN_CLEAR", | |
| "NO_ROOT_CLEAR", | |
| "ROOT_ONLY_CLEAR", | |
| "REQ_RESV_CLEAR", | |
| "LLN_CLEAR", | |
| "PDOI_CLEAR", | |
| ] | |
| req.partitions[1].flags = [ # append HIDDEN to existing LLN | |
| "HIDDEN", | |
| ] | |
| req.partitions[0].partition.oversubscribe = "NO" | |
| req.partitions[0].partition.exclusive = "TOPO" | |
| req.partitions[1].partition.oversubscribe = "FORCE:1" | |
| req.partitions[1].partition.exclusive = "USER" | |
| resp = slurm.slurm_v0046_post_partitions(req) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| # Check that they were updated | |
| resp = slurm.slurm_v0046_get_partitions() | |
| assert len(resp.errors) == 0 | |
| match_cnt = 0 | |
| for part in resp.partitions: | |
| if part.name == part_1_name: | |
| inx = 0 | |
| elif part.name == part_2_name: | |
| inx = 1 | |
| else: | |
| continue | |
| match_cnt += 1 | |
| assert ( | |
| req.partitions[inx].nodes.configured == part.nodes.configured | |
| ), "Partition nodes failed to update" | |
| if inx == 0: | |
| assert ( | |
| len(part.flags) == 0 | |
| ), f"Partition flags were not all cleared during update: {part.flags}" | |
| else: | |
| assert set(part.flags) == { | |
| "HIDDEN", | |
| "LLN", | |
| }, "Partition flags did not update correctly" | |
| assert ( | |
| req.partitions[inx].partition.oversubscribe == part.partition.oversubscribe | |
| ), "Partition oversubscribe failed to update" | |
| assert ( | |
| req.partitions[inx].partition.exclusive == part.partition.exclusive | |
| ), "Partition exclusive failed to update" | |
| assert match_cnt == 2 | |
| atf.run_command_output(f"scontrol delete partition {part_1_name}") | |
| atf.run_command_output(f"scontrol delete partition {part_2_name}") | |
| def test_delete_partition(slurm, admin_level): | |
| partition_name = "new_partition" | |
| atf.run_command_output(f"scontrol create partitionname={partition_name}") | |
| assert partition_name in atf.get_partitions(), "Failed to create partition" | |
| resp = slurm.slurm_v0046_delete_partition(partition_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert partition_name not in atf.get_partitions(), "Failed to delete partition" | |
| def test_nodes(slurm, admin_level): | |
| from openapi_client.models.v0046_update_node_msg import V0046UpdateNodeMsg | |
| node_name = None | |
| reasonuid = None | |
| resp = slurm.slurm_v0046_get_nodes() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.nodes | |
| for node in resp.nodes: | |
| if "IDLE" in node.state: | |
| node_name = node.name | |
| # comment = node.comment | |
| extra = node.extra | |
| feat = node.features | |
| actfeat = node.active_features | |
| # state = node.state | |
| reason = node.reason | |
| reasonuid = node.reason_set_by_user | |
| break | |
| # Skip if no idle nodes are found | |
| if node_name is None: | |
| pytest.fail("No idle nodes are found") | |
| if reasonuid is None or len(reasonuid) <= 0: | |
| reasonuid = local_user_name | |
| node = V0046UpdateNodeMsg( | |
| comment="test node comment", | |
| extra="test node extra", | |
| features=[ | |
| "feat1", | |
| "feat2", | |
| "feat3", | |
| ], | |
| features_act=[ | |
| "feat1", | |
| "feat3", | |
| ], | |
| state=["DRAIN"], | |
| reason="testing", | |
| reason_uid=local_user_name, | |
| ) | |
| resp = slurm.slurm_v0046_post_node(node_name, v0046_update_node_msg=node) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurm.slurm_v0046_get_node(node_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.nodes | |
| for node in resp.nodes: | |
| assert node.name == node_name | |
| assert node.comment == "test node comment" | |
| assert node.extra == "test node extra" | |
| assert "DRAIN" in node.state | |
| assert node.reason == "testing" | |
| assert node.reason_set_by_user == local_user_name | |
| ncomment = "test comment comment 2" | |
| node = V0046UpdateNodeMsg( | |
| comment=ncomment, | |
| extra=extra, | |
| features=feat, | |
| features_act=actfeat, | |
| state=["RESUME"], | |
| reason=reason, | |
| reason_uid=reasonuid, | |
| ) | |
| resp = slurm.slurm_v0046_post_node(node_name, node) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| resp = slurm.slurm_v0046_get_node(node_name) | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.nodes | |
| for node in resp.nodes: | |
| assert node.name == node_name | |
| assert node.comment == ncomment | |
| assert node.extra == extra | |
| def test_slurmrestd_update_node_suspend_time_power_save_disabled(slurm, admin_level): | |
| """A finite suspend_time update is rejected while power save is disabled""" | |
| atf.require_config_parameter("SuspendProgram", None) | |
| resp = slurm.slurm_v0046_get_nodes() | |
| assert not resp.errors, f"unexpected errors: {resp.errors}" | |
| assert resp.nodes, "expected at least one node" | |
| node_name = resp.nodes[0].name | |
| body = _slurmrestd_post( | |
| f"/slurm/v0.0.46/node/{node_name}", {"suspend_time": 300}, expect_status=422 | |
| ) | |
| assert body.get("errors"), ( | |
| "Updating suspend_time over REST should be rejected when power save" | |
| f" is not configured, got: {body}" | |
| ) | |
| # HTTP 422 covers many Slurm errors, so pin ESLURM_DISABLED (2037) | |
| assert 2037 in [ | |
| e.get("error_number") for e in body["errors"] | |
| ], f"expected ESLURM_DISABLED (2037) from update_node(), got: {body['errors']}" | |
| def test_slurmrestd_slurm_nodes_update_time(slurm, admin_level): | |
| """GET /nodes with update_time from a prior last_update succeeds.""" | |
| resp = slurm.slurm_v0046_get_nodes() | |
| assert not resp.errors, f"unexpected errors: {resp.errors}" | |
| assert resp.last_update is not None, "GET /nodes missing last_update" | |
| node_name = None | |
| for node in resp.nodes: | |
| if node.name: | |
| node_name = node.name | |
| break | |
| assert node_name, "expected at least one node from GET /nodes" | |
| expected_nodes = {node.name for node in resp.nodes if node.name} | |
| before_update = resp.last_update.number | |
| atf.run_command( | |
| f"scontrol update nodename={node_name} " "comment=slurmrestd-nodes-update-time", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| ) | |
| update_time = str(before_update) | |
| r = atf.request_slurmrestd(f"slurm/v0.0.46/nodes?update_time={update_time}") | |
| assert r.status_code == 200, ( | |
| f"GET /nodes with update_time={update_time} " | |
| f"returned HTTP {r.status_code}: {r.text[:500]}" | |
| ) | |
| body = r.json() | |
| assert not body.get("errors"), ( | |
| f"GET /nodes with update_time={update_time} " | |
| f"returned errors: {body.get('errors')}" | |
| ) | |
| assert not body.get("warnings"), ( | |
| f"GET /nodes with update_time={update_time} " | |
| f"returned warnings: {body.get('warnings')}" | |
| ) | |
| nodes = body.get("nodes") | |
| assert nodes, "expected the changed node to be returned for a diverged update_time" | |
| changed = next((n for n in nodes if n.get("name") == node_name), None) | |
| assert changed, f"changed node {node_name} missing from response" | |
| assert ( | |
| changed.get("comment") == "slurmrestd-nodes-update-time" | |
| ), f"changed node {node_name} did not reflect the updated comment" | |
| returned_nodes = {n.get("name") for n in nodes if n.get("name")} | |
| assert returned_nodes >= expected_nodes, ( | |
| "expected the entire node collection to be returned for a diverged " | |
| f"update_time, missing: {expected_nodes - returned_nodes}" | |
| ) | |
| after_update = body.get("last_update", {}).get("number") | |
| assert after_update, "response missing last_update" | |
| assert ( | |
| after_update >= before_update | |
| ), f"last_update ({after_update}) went backwards past update_time ({before_update})" | |
| def test_slurmrestd_slurm_nodes_update_time_no_change(slurm): | |
| """GET /nodes with a future update_time returns 304 Not Modified.""" | |
| r = atf.request_slurmrestd("slurm/v0.0.46/nodes?update_time=4102444800") | |
| assert r.status_code == 304, ( | |
| f"GET /nodes with a future update_time " | |
| f"returned HTTP {r.status_code}: {r.text[:500]}" | |
| ) | |
| assert not r.text, f"expected an empty body on 304, got: {r.text[:500]}" | |
| def test_ping(slurm): | |
| resp = slurm.slurm_v0046_get_ping() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| def test_diag(slurm): | |
| resp = slurm.slurm_v0046_get_diag() | |
| assert len(resp.warnings) == 0 | |
| assert len(resp.errors) == 0 | |
| assert resp.statistics | |
| def test_licenses(slurm): | |
| resp = slurm.slurm_v0046_get_licenses() | |
| assert len(resp.errors) == 0 | |
| @pytest.mark.parametrize( | |
| "flags", | |
| [[], ["IGNORE_JOBS"], ["IGNORE_JOBS", "MAGNETIC"]], | |
| ) | |
| def test_reservations(slurm, flags, admin_level): | |
| from openapi_client.models.v0046_reservation_desc_msg import V0046ReservationDescMsg | |
| from openapi_client.models.v0046_reservation_mod_req import V0046ReservationModReq | |
| from openapi_client.models.v0046_uint32_no_val_struct import V0046Uint32NoValStruct | |
| from openapi_client.models.v0046_uint64_no_val_struct import V0046Uint64NoValStruct | |
| resv_name = "test_resv" | |
| users = ["root", "atf"] | |
| duration = V0046Uint32NoValStruct(number=300, set=True) | |
| start_time = V0046Uint64NoValStruct(number=int(time.time()) + 60, set=True) | |
| end_time = V0046Uint64NoValStruct( | |
| number=start_time.number + duration.number * 60, set=True | |
| ) | |
| partition = "primary" | |
| node_list = ["node1", "node7"] | |
| # Create a reservation | |
| logging.debug(f"Creating reservation '{resv_name}'...") | |
| reservation_info = V0046ReservationDescMsg( | |
| name=resv_name, | |
| users=users, | |
| duration=duration, | |
| start_time=start_time, | |
| node_list=node_list, | |
| partition=partition, | |
| flags=flags, | |
| ) | |
| resp = slurm.slurm_v0046_post_reservations( | |
| V0046ReservationModReq(reservations=[reservation_info]) | |
| ) | |
| assert resp.reservations, f"Reservation {resv_name} should be created" | |
| assert not resp.warnings and not resp.errors | |
| # Verify the fields of the response | |
| retrieved_reservation = resp.reservations[0] | |
| assert ( | |
| retrieved_reservation.name == resv_name | |
| ), f"Field 'name' should be {resv_name}" | |
| assert ( | |
| retrieved_reservation.partition == partition | |
| ), f"Field 'partition' should be {partition}" | |
| assert ( | |
| retrieved_reservation.node_list == node_list | |
| ), f"Field 'node_list' should be {node_list}" | |
| assert retrieved_reservation.users == users, f"Field 'users' should be {users}" | |
| assert ( | |
| retrieved_reservation.start_time.number == start_time.number | |
| ), f"Field 'start_time' should be {start_time}" | |
| assert ( | |
| retrieved_reservation.duration.number == duration.number | |
| ), f"Field 'duration' should be {duration}" | |
| assert ( | |
| # SPEC_NODES is automatically added | |
| set([flag for flag in retrieved_reservation.flags if flag != "SPEC_NODES"]) | |
| == set(flags) | |
| ), f"Field 'flags' should be {flags}" | |
| # Verify fields of reservation created | |
| resp = slurm.slurm_v0046_get_reservation(resv_name) | |
| assert len(resp.reservations) == 1, f"Reservation '{resv_name}' should be returned" | |
| retrieved_reservation = resp.reservations[0] | |
| assert ( | |
| retrieved_reservation.name == resv_name | |
| ), f"Field 'name' should be {resv_name}" | |
| assert ( | |
| retrieved_reservation.partition == partition | |
| ), f"Field 'partition' should be {partition}" | |
| assert ( | |
| atf.node_range_to_list(retrieved_reservation.node_list) == node_list | |
| ), f"Field 'node_list' should be {node_list}" | |
| assert ( | |
| retrieved_reservation.users.split(",") == users | |
| ), f"Field 'users' should be {users}" | |
| assert ( | |
| retrieved_reservation.start_time.number == start_time.number | |
| ), f"Field 'start_time' should be {start_time}" | |
| assert ( | |
| retrieved_reservation.end_time.number == end_time.number | |
| ), f"Field 'end_time' should be {end_time}" | |
| assert ( | |
| # SPEC_NODES is automatically added | |
| set([flag for flag in retrieved_reservation.flags if flag != "SPEC_NODES"]) | |
| == set(flags) | |
| ), f"Field 'flags' should be {flags}" | |
| # Update reservation | |
| new_end_time = retrieved_reservation.end_time.number + 300 | |
| new_users = ["root"] | |
| new_node_list = ["node2"] | |
| new_start_time = V0046Uint64NoValStruct(number=int(time.time()) + 90, set=True) | |
| new_flags = ["IGNORE_JOBS", "MAGNETIC", "USER_DELETE"] | |
| update_info = V0046ReservationDescMsg( | |
| name=resv_name, | |
| end_time=V0046Uint64NoValStruct(set=True, number=new_end_time), | |
| users=new_users, | |
| node_list=new_node_list, | |
| start_time=new_start_time, | |
| flags=new_flags, | |
| ) | |
| resp = slurm.slurm_v0046_post_reservation(update_info) | |
| assert resp.reservations, "Reservation should be updated" | |
| assert not resp.warnings and not resp.errors | |
| # Validate update | |
| resp = slurm.slurm_v0046_get_reservation(resv_name) | |
| assert len(resp.reservations) == 1, f"Reservation '{resv_name}' should be returned" | |
| updated = resp.reservations[0] | |
| assert updated.end_time.number == new_end_time | |
| assert updated.users.split(",") == new_users, f"Field 'users' should be {new_users}" | |
| assert ( | |
| atf.node_range_to_list(updated.node_list) == new_node_list | |
| ), f"Field 'node_list' should be {new_node_list}" | |
| assert ( | |
| updated.start_time.number == new_start_time.number | |
| ), f"Field 'start_time' should be {new_start_time}" | |
| assert ( | |
| # SPEC_NODES is automatically added | |
| set([flag for flag in updated.flags if flag != "SPEC_NODES"]) | |
| == set(new_flags) | |
| ), f"Field 'flags' should be {flags}" | |
| # Delete reservation | |
| resp = slurm.slurm_v0046_delete_reservation(reservation_name=resv_name) | |
| assert not resp.warnings and not resp.errors | |
| assert resv_name not in [ | |
| r.name for r in slurm.slurm_v0046_get_reservations().reservations | |
| ], f"Reservation {resv_name} should be deleted" | |
| @pytest.mark.parametrize("legal_state", ["CLOUD", "FUTURE", "EXTERNAL"]) | |
| def test_legal_node_creation(slurm, admin_level, legal_state, dynamic_node): | |
| from openapi_client.models.v0046_openapi_create_node_req import ( | |
| V0046OpenapiCreateNodeReq, | |
| ) | |
| request_body = V0046OpenapiCreateNodeReq( | |
| node_conf=f"nodename={dynamic_node} State={legal_state}" | |
| ) | |
| response = slurm.slurm_v0046_post_new_node( | |
| v0046_openapi_create_node_req=request_body | |
| ) | |
| # Validate there are no errors or warnings, and the meta data exits | |
| assert not response.errors | |
| assert not response.warnings | |
| assert response.meta | |
| # Validate the node exists and is in the correct state | |
| node_states = atf.get_node_parameter(dynamic_node, "state") | |
| assert ( | |
| legal_state in node_states | |
| ), f"Dynamic node should have {legal_state} in its state only has {node_states}" | |
| assert ( | |
| "DYNAMIC_NORM" in node_states | |
| ), "Dynamic node should have DYNAMIC_NORM in its state" | |
| @pytest.mark.parametrize( | |
| "illegal_state", ["DOWN", "DRAIN", "FAIL", "FAILING", "UNKNOWN"] | |
| ) | |
| def test_illegal_node_creation(slurm, admin_level, illegal_state, dynamic_node): | |
| from openapi_client.api_response import ApiResponse | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_openapi_create_node_req import ( | |
| V0046OpenapiCreateNodeReq, | |
| ) | |
| request_body = V0046OpenapiCreateNodeReq( | |
| node_conf=f"nodename={dynamic_node} State={illegal_state}" | |
| ) | |
| try: | |
| # This should throw an exception | |
| slurm.slurm_v0046_post_new_node_with_http_info( | |
| v0046_openapi_create_node_req=request_body | |
| ) | |
| assert ( | |
| False | |
| ), "slurm_v0046_post_new_node_with_http_info should have raised an exception" | |
| except ApiException as e: | |
| response = slurm.api_client.deserialize( | |
| response=ApiResponse(data=e.body), response_type="V0046OpenapiResp" | |
| ) | |
| # Validate there is an error and no warnings | |
| assert e.status != 200 | |
| assert len(response.errors) == 1 # error about wrong state | |
| assert not response.warnings | |
| assert response.meta | |
| # Validate the node doesn't exist | |
| atf.run_command( | |
| f"scontrol show NodeName={dynamic_node}", | |
| user="slurm", | |
| xfail=True, | |
| fatal=True, | |
| ) | |
| # Test until endpoints | |
| @pytest.fixture | |
| def util_api(setup): | |
| yield atf.openapi_util() | |
| def test_util_hostnames(util_api): | |
| from openapi_client.models.v0046_openapi_hostlist_req_resp import ( | |
| V0046OpenapiHostlistReqResp, | |
| ) | |
| hostnames = ["node01", "node02", "node03"] | |
| request_body = V0046OpenapiHostlistReqResp(hostlist="node[01-03]") | |
| response = util_api.util_v0046_post_hostnames( | |
| v0046_openapi_hostlist_req_resp=request_body | |
| ) | |
| assert response.hostnames is not None | |
| assert isinstance(response.hostnames, list) | |
| assert all(isinstance(host, str) for host in response.hostnames) | |
| assert hostnames == response.hostnames | |
| def test_util_hostlist(util_api): | |
| from openapi_client.models.v0046_openapi_hostnames_req_resp import ( | |
| V0046OpenapiHostnamesReqResp, | |
| ) | |
| request_body = V0046OpenapiHostnamesReqResp( | |
| hostnames=["node01", "node02", "node03"] | |
| ) | |
| response = util_api.util_v0046_post_hostlist( | |
| v0046_openapi_hostnames_req_resp=request_body | |
| ) | |
| assert response.hostlist is not None | |
| assert isinstance(response.hostlist, str) | |
| assert "node[01-03]" == response.hostlist | |
| def test_resv_crash(slurm, admin_level, resv_crash): | |
| """Check for xfree crash (bug 23038)""" | |
| from openapi_client.models.v0046_reservation_desc_msg import V0046ReservationDescMsg | |
| from openapi_client.models.v0046_reservation_mod_req import V0046ReservationModReq | |
| from openapi_client.models.v0046_uint32_no_val_struct import V0046Uint32NoValStruct | |
| from openapi_client.models.v0046_uint64_no_val_struct import V0046Uint64NoValStruct | |
| # Don't overlap with other resv in case of restd crash/restart | |
| resv_name = resv_crash | |
| users = ["root", "atf"] | |
| duration = V0046Uint32NoValStruct(number=1, set=True) | |
| start_time = V0046Uint64NoValStruct( | |
| number=int(time.time()) + 10000000, | |
| set=True, | |
| ) | |
| partition = "primary" | |
| # Create a reservation with empty node_list | |
| reservation_info = V0046ReservationDescMsg( | |
| name=resv_name, | |
| users=users, | |
| duration=duration, | |
| start_time=start_time, | |
| partition=partition, | |
| node_list=[], | |
| ) | |
| # Exception means restd crashed | |
| resp = slurm.slurm_v0046_post_reservations( | |
| V0046ReservationModReq(reservations=[reservation_info]) | |
| ) | |
| assert ( | |
| not resp.warnings and not resp.errors | |
| ), "We should be able to get the server response from this message" | |
| # Fields that legitimately differ between two independent dumps of the same | |
| # config: runtime counters and the build/boot/update timestamps. The CLI and | |
| # REST calls each sample a separate RPC, so these can disagree without a bug. | |
| conf_volatile_fields = { | |
| "BOOT_TIME", | |
| "NEXT_JOB_ID", | |
| "next_job_id", | |
| "LastUpdate", | |
| "controllers", | |
| } | |
| def test_slurmrestd_slurm_conf(slurm): | |
| """GET /slurm/v0.0.46/conf returns a well-formed slurm.conf payload.""" | |
| resp = slurm.slurm_v0046_get_conf() | |
| assert not resp.errors, f"unexpected errors: {resp.errors}" | |
| assert not resp.warnings, f"unexpected warnings: {resp.warnings}" | |
| assert resp.slurm_conf, "empty slurm_conf payload" | |
| assert resp.slurm_conf.cluster_name == atf.get_config_parameter( | |
| "ClusterName" | |
| ), "slurm_conf ClusterName does not match the running config" | |
| def test_slurmrestd_slurm_conf_update_time_no_change(slurm): | |
| """A future update_time yields a no-change response with an empty body.""" | |
| resp = slurm.slurm_v0046_get_conf(update_time="4102444800") | |
| assert not resp.errors, f"unexpected errors: {resp.errors}" | |
| assert resp.warnings, "expected a no-change warning" | |
| assert not resp.slurm_conf, "expected empty body on no-change" | |
| def test_slurmrestd_slurm_conf_update_time_past(slurm): | |
| """A past update_time still returns the full slurm.conf payload.""" | |
| resp = slurm.slurm_v0046_get_conf(update_time="1") | |
| assert not resp.errors, f"unexpected errors: {resp.errors}" | |
| assert resp.slurm_conf, "expected a full payload for a past update_time" | |
| def test_slurmrestd_slurm_conf_rejects_non_get(): | |
| """Only GET is registered for /slurm/v0.0.46/conf.""" | |
| r = requests.post( | |
| f"{atf.properties['slurmrestd_url']}/slurm/v0.0.46/conf", | |
| headers=atf.properties["slurmrestd-headers"], | |
| timeout=30, | |
| ) | |
| # A non-GET on a GET-only path is not served: slurmrestd's router returns | |
| # 404 (ESLURM_REST_UNKNOWN_URL) today. Accept 405 too so a future change to | |
| # the more correct status doesn't break this. | |
| assert r.status_code in ( | |
| 404, | |
| 405, | |
| ), f"expected 404/405, got {r.status_code}: {r.text[:200]}" | |
| def test_scontrol_show_config_json(): | |
| """`scontrol show config --json=v0.0.46` emits a well-formed slurm_conf.""" | |
| blob = json.loads( | |
| atf.run_command_output("scontrol show config --json=v0.0.46", fatal=True) | |
| ) | |
| assert blob["errors"] == [], f"unexpected errors: {blob['errors']}" | |
| assert blob["slurm_conf"], "empty slurm_conf payload" | |
| def test_scontrol_show_config_yaml(): | |
| """`scontrol show config --yaml=v0.0.46` produces the same shape as --json.""" | |
| blob = yaml.safe_load( | |
| atf.run_command_output("scontrol show config --yaml=v0.0.46", fatal=True) | |
| ) | |
| assert blob["errors"] == [], f"unexpected errors: {blob['errors']}" | |
| assert blob["slurm_conf"], "empty slurm_conf payload" | |
| def test_scontrol_show_config_json_list(): | |
| """`scontrol show config --json=list` lists the data_parser plugins.""" | |
| out = atf.run_command_output("scontrol show config --json=list", fatal=True) | |
| assert "v0.0.46" in out, f"v0.0.46 not listed in: {out}" | |
| def test_slurm_conf_cli_matches_slurmrestd(): | |
| """scontrol --json=v0.0.46 and /slurm/v0.0.46/conf yield equivalent payloads.""" | |
| cli = json.loads( | |
| atf.run_command_output("scontrol show config --json=v0.0.46", fatal=True) | |
| )["slurm_conf"] | |
| rest = atf.request_slurmrestd("slurm/v0.0.46/conf").json()["slurm_conf"] | |
| assert cli.get("ClusterName"), "slurm_conf payload missing ClusterName" | |
| cli = {k: v for k, v in cli.items() if k not in conf_volatile_fields} | |
| rest = {k: v for k, v in rest.items() if k not in conf_volatile_fields} | |
| assert cli == rest, "scontrol and slurmrestd slurm_conf payloads differ" | |
| def test_slurmrestd_slurmdbd_conf(slurmdb): | |
| """GET /slurmdb/v0.0.46/conf returns a well-formed slurmdbd.conf payload.""" | |
| resp = slurmdb.slurmdb_v0046_get_conf() | |
| assert not resp.errors, f"unexpected errors: {resp.errors}" | |
| assert not resp.warnings, f"unexpected warnings: {resp.warnings}" | |
| assert resp.slurmdbd_conf, "empty slurmdbd_conf payload" | |
| assert resp.slurmdbd_conf.dbd_host, "slurmdbd_conf missing DbdHost" | |
| def test_slurmrestd_slurmdbd_conf_rejects_non_get(): | |
| """Only GET is registered for /slurmdb/v0.0.46/conf.""" | |
| r = requests.post( | |
| f"{atf.properties['slurmrestd_url']}/slurmdb/v0.0.46/conf", | |
| headers=atf.properties["slurmrestd-headers"], | |
| timeout=30, | |
| ) | |
| # See test_slurmrestd_slurm_conf_rejects_non_get: a non-GET on a | |
| # GET-only path is not served (404 today, 405 acceptable). | |
| assert r.status_code in ( | |
| 404, | |
| 405, | |
| ), f"expected 404/405, got {r.status_code}: {r.text[:200]}" | |
| def test_sacctmgr_show_config_json(): | |
| """`sacctmgr show config --json=v0.0.46` emits a well-formed slurmdbd_conf.""" | |
| blob = json.loads( | |
| atf.run_command_output("sacctmgr show config --json=v0.0.46", fatal=True) | |
| ) | |
| assert blob["errors"] == [], f"unexpected errors: {blob['errors']}" | |
| assert blob["slurmdbd_conf"], "empty slurmdbd_conf payload" | |
| def test_sacctmgr_show_config_yaml(): | |
| """`sacctmgr show config --yaml=v0.0.46` produces the same shape as --json.""" | |
| blob = yaml.safe_load( | |
| atf.run_command_output("sacctmgr show config --yaml=v0.0.46", fatal=True) | |
| ) | |
| assert blob["errors"] == [], f"unexpected errors: {blob['errors']}" | |
| assert blob["slurmdbd_conf"], "empty slurmdbd_conf payload" | |
| def test_sacctmgr_show_config_json_list(): | |
| """`sacctmgr show config --json=list` lists the data_parser plugins.""" | |
| out = atf.run_command_output("sacctmgr show config --json=list", fatal=True) | |
| assert "v0.0.46" in out, f"v0.0.46 not listed in: {out}" | |
| def test_slurmdbd_conf_cli_matches_slurmrestd(): | |
| """sacctmgr --json=v0.0.46 and /slurmdb/v0.0.46/conf yield equivalent payloads.""" | |
| cli = json.loads( | |
| atf.run_command_output("sacctmgr show config --json=v0.0.46", fatal=True) | |
| )["slurmdbd_conf"] | |
| rest = atf.request_slurmrestd("slurmdb/v0.0.46/conf").json()["slurmdbd_conf"] | |
| assert cli.get("DbdHost"), "slurmdbd_conf payload missing DbdHost" | |
| cli = {k: v for k, v in cli.items() if k not in conf_volatile_fields} | |
| rest = {k: v for k, v in rest.items() if k not in conf_volatile_fields} | |
| assert cli == rest, "sacctmgr and slurmrestd slurmdbd_conf payloads differ" | |
| def test_default_topology(slurm): | |
| """GET /slurm/v0.0.46/default/topology/ returns the cluster's default topology.""" | |
| from openapi_client.models.v0046_topo_info import V0046TopoInfo | |
| from openapi_client.models.v0046_topology_flat_config import V0046TopologyFlatConfig | |
| expected = V0046TopoInfo( | |
| block=[], | |
| flat=V0046TopologyFlatConfig(alpha_step_rank=True), | |
| ring=[], | |
| torus3d=[], | |
| tree=[], | |
| ) | |
| r = slurm.slurm_v0046_get_default_topology() | |
| assert len(r.errors) == 0, f"unexpected errors: {r.errors}" | |
| assert len(r.warnings) == 0, f"unexpected warnings: {r.warnings}" | |
| assert ( | |
| r.topology == expected | |
| ), "GET /slurm/v0.0.46/default/topology did not return the cluster default" | |
| def _expected_topo_info(name): | |
| """Return the V0046TopoInfo that the named topology should dump.""" | |
| from openapi_client.models.v0046_topo_info import V0046TopoInfo | |
| from openapi_client.models.v0046_topo_info_block_record import ( | |
| V0046TopoInfoBlockRecord, | |
| ) | |
| from openapi_client.models.v0046_topo_info_ring_record import ( | |
| V0046TopoInfoRingRecord, | |
| ) | |
| from openapi_client.models.v0046_topo_info_torus3d_placement import ( | |
| V0046TopoInfoTorus3dPlacement, | |
| ) | |
| from openapi_client.models.v0046_topo_info_torus3d_record import ( | |
| V0046TopoInfoTorus3dRecord, | |
| ) | |
| from openapi_client.models.v0046_topo_info_tree_record import ( | |
| V0046TopoInfoTreeRecord, | |
| ) | |
| from openapi_client.models.v0046_topology_flat_config import V0046TopologyFlatConfig | |
| from openapi_client.models.v0046_torus3d_dims import V0046Torus3dDims | |
| tree_expected = V0046TopoInfo( | |
| block=[], | |
| flat={}, | |
| ring=[], | |
| torus3d=[], | |
| tree=[ | |
| V0046TopoInfoTreeRecord( | |
| level=1, | |
| link_speed=0, | |
| name="sw_root", | |
| nodes="node[1-4]", | |
| switches="s[1-2]", | |
| ), | |
| V0046TopoInfoTreeRecord( | |
| level=0, link_speed=0, name="s1", nodes="node[1-2]", switches="" | |
| ), | |
| V0046TopoInfoTreeRecord( | |
| level=0, link_speed=0, name="s2", nodes="node[3-4]", switches="" | |
| ), | |
| ], | |
| ) | |
| block_expected = V0046TopoInfo( | |
| block=[ | |
| V0046TopoInfoBlockRecord( | |
| aggregated=False, | |
| block_index=0, | |
| name="b1", | |
| nodes="node[1-2]", | |
| block_size=2, | |
| ), | |
| V0046TopoInfoBlockRecord( | |
| aggregated=False, | |
| block_index=1, | |
| name="b2", | |
| nodes="node[3-4]", | |
| block_size=2, | |
| ), | |
| V0046TopoInfoBlockRecord( | |
| aggregated=False, | |
| block_index=2, | |
| name="b3", | |
| nodes="node[5-6]", | |
| block_size=2, | |
| ), | |
| V0046TopoInfoBlockRecord( | |
| aggregated=False, | |
| block_index=3, | |
| name="b4", | |
| nodes="node[7-8]", | |
| block_size=2, | |
| ), | |
| V0046TopoInfoBlockRecord( | |
| aggregated=True, | |
| block_index=4, | |
| name="b[1-2]", | |
| nodes="node[1-4]", | |
| block_size=4, | |
| ), | |
| V0046TopoInfoBlockRecord( | |
| aggregated=True, | |
| block_index=5, | |
| name="b[3-4]", | |
| nodes="node[5-8]", | |
| block_size=4, | |
| ), | |
| ], | |
| flat={}, | |
| ring=[], | |
| torus3d=[], | |
| tree=[], | |
| ) | |
| flat1_expected = V0046TopoInfo( | |
| block=[], | |
| flat=V0046TopologyFlatConfig(alpha_step_rank=False), | |
| ring=[], | |
| torus3d=[], | |
| tree=[], | |
| ) | |
| flat2_expected = V0046TopoInfo( | |
| block=[], | |
| flat=V0046TopologyFlatConfig(alpha_step_rank=True), | |
| ring=[], | |
| torus3d=[], | |
| tree=[], | |
| ) | |
| ring_expected = V0046TopoInfo( | |
| block=[], | |
| flat={}, | |
| ring=[ | |
| V0046TopoInfoRingRecord( | |
| name="ring0", nodes="node[1-4]", ring_index=0, size=4 | |
| ), | |
| V0046TopoInfoRingRecord( | |
| name="ring1", nodes="node[5-8]", ring_index=1, size=4 | |
| ), | |
| ], | |
| torus3d=[], | |
| tree=[], | |
| ) | |
| torus_expected = V0046TopoInfo( | |
| block=[], | |
| flat={}, | |
| ring=[], | |
| torus3d=[ | |
| V0046TopoInfoTorus3dRecord( | |
| name="pod1", | |
| nodes="node[1-8]", | |
| dims=V0046Torus3dDims(x=2, y=2, z=2), | |
| placements=[ | |
| V0046TopoInfoTorus3dPlacement( | |
| dims=V0046Torus3dDims(x=1, y=1, z=1), | |
| placement_size=1, | |
| anchors=8, | |
| ), | |
| V0046TopoInfoTorus3dPlacement( | |
| dims=V0046Torus3dDims(x=1, y=1, z=2), | |
| placement_size=2, | |
| anchors=4, | |
| ), | |
| V0046TopoInfoTorus3dPlacement( | |
| dims=V0046Torus3dDims(x=2, y=2, z=2), | |
| placement_size=8, | |
| anchors=1, | |
| ), | |
| ], | |
| ) | |
| ], | |
| tree=[], | |
| ) | |
| return { | |
| "tree": tree_expected, | |
| "block": block_expected, | |
| "flat1": flat1_expected, | |
| "flat2": flat2_expected, | |
| "flat_shorthand": flat1_expected, | |
| "ring": ring_expected, | |
| "torus": torus_expected, | |
| }[name] | |
| @pytest.mark.parametrize("name", topology_names) | |
| def test_topology_by_name(slurm, name): | |
| """GET /slurm/v0.0.46/topology/{topology_name} returns that topology.""" | |
| r = slurm.slurm_v0046_get_topology(topology_name=name) | |
| assert len(r.errors) == 0, f"unexpected errors for {name}: {r.errors}" | |
| assert len(r.warnings) == 0, f"unexpected warnings for {name}: {r.warnings}" | |
| assert r.topology == _expected_topo_info( | |
| name | |
| ), f"GET /slurm/v0.0.46/topology/{name} returned the wrong payload" | |
| def test_topology_unknown_name(slurm): | |
| """An unconfigured topology name is rejected rather than returning data.""" | |
| from openapi_client.api_response import ApiResponse | |
| from openapi_client.exceptions import ApiException | |
| try: | |
| # This should throw an exception | |
| slurm.slurm_v0046_get_topology_with_http_info("non_existent_topology") | |
| assert ( | |
| False | |
| ), "slurm_v0046_get_topology_with_http_info should have raised an exception" | |
| except ApiException as e: | |
| response = slurm.api_client.deserialize( | |
| response=ApiResponse(data=e.body), response_type="V0046OpenapiTopoInfoResp" | |
| ) | |
| # Validate there is an error and no warnings | |
| assert e.status == 404, f"expected HTTP 404, got {e.status}" | |
| assert ( | |
| len(response.errors) == 1 | |
| ), f"expected exactly one error, got {response.errors}" | |
| assert ( | |
| response.errors[0].error == "Requested topology not found" | |
| ), f"unexpected error text: {response.errors[0].error}" | |
| assert not response.warnings, f"unexpected warnings: {response.warnings}" | |
| assert response.meta, "error response carried no meta" | |
| @pytest.mark.parametrize("name", topology_names) | |
| def test_topology_cli_matches_slurmrestd(name): | |
| """scontrol --json=v0.0.46 and /slurm/v0.0.46/topology/{name} agree.""" | |
| cli = json.loads( | |
| atf.run_command_output( | |
| f"scontrol show topology {name} --json=v0.0.46", fatal=True | |
| ) | |
| )["topology"] | |
| rest = atf.request_slurmrestd(f"slurm/v0.0.46/topology/{name}").json()["topology"] | |
| assert cli == rest, f"scontrol and slurmrestd topology payloads differ for {name}" | |
| def test_default_topology_cli_matches_slurmrestd(): | |
| """scontrol --json=v0.0.46 and /slurm/v0.0.46/default/topology agree.""" | |
| cli = json.loads( | |
| atf.run_command_output("scontrol show topology --json=v0.0.46", fatal=True) | |
| )["topology"] | |
| rest = atf.request_slurmrestd("slurm/v0.0.46/default/topology").json()["topology"] | |
| assert cli == rest, "scontrol and slurmrestd default topology payloads differ" | |
| @pytest.mark.parametrize( | |
| "path", ["slurm/v0.0.46/default/topology", "slurm/v0.0.46/topology/tree"] | |
| ) | |
| def test_slurmrestd_topology_rejects_non_get(path): | |
| """Only GET is registered for the topology endpoints.""" | |
| r = requests.post( | |
| f"{atf.properties['slurmrestd_url']}/{path}", | |
| headers=atf.properties["slurmrestd-headers"], | |
| timeout=30, | |
| ) | |
| # A non-GET on a GET-only path is not served: slurmrestd's router returns | |
| # 404 (ESLURM_REST_UNKNOWN_URL) today. Accept 405 too so a future change to | |
| # the more correct status doesn't break this. | |
| assert r.status_code in ( | |
| 404, | |
| 405, | |
| ), f"expected 404/405 for POST /{path}, got {r.status_code}: {r.text[:200]}" | |
| def _assert_job_requeue(jobid, expected_state, expected_reason, held): | |
| for t in atf.timer(fatal=True): | |
| if int(atf.get_job_parameter(jobid, "Restarts", quiet=True)) > 0: | |
| break | |
| atf.wait_for_job_state( | |
| jobid, | |
| expected_state, | |
| desired_reason=expected_reason, | |
| fatal=True, | |
| ) | |
| if held: | |
| assert ( | |
| atf.get_job_parameter(jobid, "Priority", quiet=True) == 0 | |
| ), "Held job should have priority 0" | |
| def _get_running_array_tasks(jobid, task_count): | |
| atf.wait_for_job_state(jobid, "RUNNING", fatal=True) | |
| task_ids = list(atf.get_jobs(job_id=jobid, quiet=True)) | |
| assert len(task_ids) == task_count, f"Array job should have {task_count} tasks" | |
| for task_id in task_ids: | |
| atf.wait_for_job_state(task_id, "RUNNING", fatal=True) | |
| return task_ids | |
| @pytest.mark.parametrize( | |
| "query_params,expected_state,expected_reason", | |
| [ | |
| pytest.param({}, "PENDING", "BeginTime", id="no_flags"), | |
| pytest.param({"incomplete": "True"}, "PENDING", "BeginTime", id="incomplete"), | |
| pytest.param( | |
| {"hold": "True"}, "PENDING", "job_requeued_in_held_state", id="hold" | |
| ), | |
| pytest.param( | |
| {"hold": "True", "special_exit": "True"}, | |
| "SPECIAL_EXIT", | |
| "job_requeued_in_held_state", | |
| id="special_exit", | |
| ), | |
| ], | |
| ) | |
| def test_job_requeue(slurm, query_params, expected_state, expected_reason, non_admin): | |
| """Test single job requeue endpoint""" | |
| jobid = atf.submit_job_sbatch("--requeue --wrap='sleep infinity' -a1-2", fatal=True) | |
| task_ids = _get_running_array_tasks(jobid, 2) | |
| # Requeue it | |
| resp = slurm.slurm_v0046_get_job_requeue(str(jobid), **query_params) | |
| assert not resp.warnings, "Requeue response should have no warnings" | |
| assert not resp.errors, "Requeue response should have no errors" | |
| # Verify that the response has a status since it is an array job | |
| assert len(resp.status) == 1, "Array job requeue should populate status" | |
| assert resp.status[0].job_id == jobid, "Status job_id should match requeued job" | |
| assert resp.status[0].error_code == 0, "Requeue status should have no error" | |
| # Make sure every task of the array requeued | |
| for task_id in task_ids: | |
| _assert_job_requeue( | |
| task_id, expected_state, expected_reason, "hold" in query_params | |
| ) | |
| def test_job_requeue_incomplete(slurm, non_admin): | |
| """The Incomplete flag skips jobs that have already completed""" | |
| jobid = atf.submit_job_sbatch("--requeue --wrap='exit 0'", fatal=True) | |
| atf.wait_for_job_state(jobid, "COMPLETED", fatal=True) | |
| # With the Incomplete flag the completed job is left untouched | |
| resp = slurm.slurm_v0046_get_job_requeue(str(jobid), incomplete="True") | |
| assert not resp.errors, "Requeue response should have no errors" | |
| assert ( | |
| atf.get_job_parameter(jobid, "JobState", quiet=True) == "COMPLETED" | |
| ), "Incomplete flag should not requeue a completed job" | |
| assert ( | |
| int(atf.get_job_parameter(jobid, "Restarts", quiet=True)) == 0 | |
| ), "Incomplete flag should not requeue a completed job" | |
| # Without the flag the same completed job is requeued | |
| resp = slurm.slurm_v0046_get_job_requeue(str(jobid)) | |
| assert not resp.errors, "Requeue response should have no errors" | |
| _assert_job_requeue(jobid, "PENDING", "BeginTime", False) | |
| @pytest.mark.parametrize( | |
| "requeue_flags,expected_state,expected_reason,held", | |
| [ | |
| pytest.param(None, "PENDING", "BeginTime", False, id="no_flags"), | |
| pytest.param( | |
| ["Hold"], "PENDING", "job_requeued_in_held_state", True, id="hold" | |
| ), | |
| pytest.param( | |
| ["Hold", "SpecialExit"], | |
| "SPECIAL_EXIT", | |
| "job_requeued_in_held_state", | |
| True, | |
| id="special_exit", | |
| ), | |
| ], | |
| ) | |
| def test_jobs_requeue( | |
| slurm, requeue_flags, expected_state, expected_reason, held, non_admin | |
| ): | |
| """Test batch jobs requeue endpoint""" | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| jobids = [] | |
| jobids.append( | |
| atf.submit_job_sbatch("--requeue --wrap='sleep infinity' -a1-2", fatal=True) | |
| ) | |
| jobids.append( | |
| atf.submit_job_sbatch("--requeue --wrap='sleep infinity' -a1-2", fatal=True) | |
| ) | |
| # Wait for every task of both arrays to go from PENDING to RUNNING | |
| task_ids = [] | |
| for jobid in jobids: | |
| task_ids.extend(_get_running_array_tasks(jobid, 2)) | |
| # Batch requeue both jobs | |
| requeue_params = { | |
| "jobs": [str(jid) for jid in jobids], | |
| } | |
| if requeue_flags: | |
| requeue_params["flags"] = requeue_flags | |
| requeue_req = V0046OpenapiJobsRequeueQuery(**requeue_params) | |
| resp = slurm.slurm_v0046_post_jobs_requeue( | |
| v0046_openapi_jobs_requeue_query=requeue_req, | |
| ) | |
| assert not resp.warnings, "Requeue response should have no warnings" | |
| assert not resp.errors, "Requeue response should have no errors" | |
| # Verify that the response has a status since these are array jobs | |
| assert len(resp.status) == 2, "Each requeued array job should have a status" | |
| for i in range(len(jobids)): | |
| assert len(resp.status[i]) == 1, "Array job status should have one entry" | |
| assert resp.status[i][0].job_id == jobids[i], "Status job_id should match" | |
| assert resp.status[i][0].error_code == 0, "Requeue status should have no error" | |
| # Make sure every task of both arrays requeued | |
| for task_id in task_ids: | |
| _assert_job_requeue(task_id, expected_state, expected_reason, held) | |
| def test_jobs_requeue_incomplete(slurm, non_admin): | |
| """The batch Incomplete flag skips only the jobs that have completed""" | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| done_id = atf.submit_job_sbatch("--requeue --wrap='exit 0'", fatal=True) | |
| atf.wait_for_job_state(done_id, "COMPLETED", fatal=True) | |
| running_id = atf.submit_job_sbatch("--requeue --wrap='sleep infinity'", fatal=True) | |
| atf.wait_for_job_state(running_id, "RUNNING", fatal=True) | |
| requeue_req = V0046OpenapiJobsRequeueQuery( | |
| jobs=[str(done_id), str(running_id)], flags=["Incomplete"] | |
| ) | |
| resp = slurm.slurm_v0046_post_jobs_requeue( | |
| v0046_openapi_jobs_requeue_query=requeue_req, | |
| ) | |
| assert not resp.warnings, "Requeue response should have no warnings" | |
| assert not resp.errors, "Requeue response should have no errors" | |
| assert ( | |
| atf.get_job_parameter(done_id, "JobState", quiet=True) == "COMPLETED" | |
| ), "Incomplete flag should not requeue a completed job" | |
| assert ( | |
| int(atf.get_job_parameter(done_id, "Restarts", quiet=True)) == 0 | |
| ), "Incomplete flag should not requeue a completed job" | |
| _assert_job_requeue(running_id, "PENDING", "BeginTime", False) | |
| def test_job_requeue_special_exit_requires_hold(slurm, non_admin): | |
| """SpecialExit without Hold is rejected by both requeue endpoints""" | |
| from openapi_client.api_response import ApiResponse | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| jobid = atf.submit_job_sbatch("--requeue --wrap='sleep infinity'", fatal=True) | |
| atf.wait_for_job_state(jobid, "RUNNING", fatal=True) | |
| # GET single job endpoint rejects SpecialExit without Hold | |
| try: | |
| slurm.slurm_v0046_get_job_requeue_with_http_info( | |
| str(jobid), special_exit="True" | |
| ) | |
| assert False, "SpecialExit without Hold should raise an exception" | |
| except ApiException as e: | |
| response = slurm.api_client.deserialize( | |
| response=ApiResponse(data=e.body), response_type="V0046OpenapiResp" | |
| ) | |
| assert e.status != 200, "Rejected request should not return HTTP 200" | |
| assert len(response.errors) == 1, "Rejected request should report one error" | |
| assert not response.warnings, "Rejected request should have no warnings" | |
| # POST batch endpoint rejects SpecialExit without Hold | |
| requeue_req = V0046OpenapiJobsRequeueQuery(jobs=[str(jobid)], flags=["SpecialExit"]) | |
| try: | |
| slurm.slurm_v0046_post_jobs_requeue_with_http_info( | |
| v0046_openapi_jobs_requeue_query=requeue_req | |
| ) | |
| assert False, "SpecialExit without Hold should raise an exception" | |
| except ApiException as e: | |
| response = slurm.api_client.deserialize( | |
| response=ApiResponse(data=e.body), response_type="V0046OpenapiResp" | |
| ) | |
| assert e.status != 200, "Rejected request should not return HTTP 200" | |
| assert len(response.errors) == 1, "Rejected request should report one error" | |
| assert not response.warnings, "Rejected request should have no warnings" | |
| # The rejected requests did not requeue or hold the job | |
| assert ( | |
| atf.get_job_parameter(jobid, "JobState", quiet=True) == "RUNNING" | |
| ), "Rejected requeue should leave the job running" | |
| def test_job_requeue_invalid_job(slurm): | |
| """Requeuing a nonexistent job reports an error""" | |
| from openapi_client.api_response import ApiResponse | |
| from openapi_client.exceptions import ApiException | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| # A job id above MaxJobId is never assigned, so it cannot exist | |
| bad_jobid = int(atf.get_config_parameter("MaxJobId")) + 1 | |
| try: | |
| slurm.slurm_v0046_get_job_requeue_with_http_info(str(bad_jobid)) | |
| assert False, "Requeuing a nonexistent job should raise an exception" | |
| except ApiException as e: | |
| response = slurm.api_client.deserialize( | |
| response=ApiResponse(data=e.body), response_type="V0046OpenapiResp" | |
| ) | |
| assert e.status != 200, "Nonexistent job requeue should not return HTTP 200" | |
| assert response.errors, "Nonexistent job requeue should report an error" | |
| requeue_req = V0046OpenapiJobsRequeueQuery(jobs=[str(bad_jobid)]) | |
| try: | |
| slurm.slurm_v0046_post_jobs_requeue_with_http_info( | |
| v0046_openapi_jobs_requeue_query=requeue_req | |
| ) | |
| assert False, "Requeuing a nonexistent job should raise an exception" | |
| except ApiException as e: | |
| response = slurm.api_client.deserialize( | |
| response=ApiResponse(data=e.body), response_type="V0046OpenapiResp" | |
| ) | |
| assert e.status != 200, "Nonexistent job requeue should not return HTTP 200" | |
| assert response.errors, "Nonexistent job requeue should report an error" | |
| @pytest.mark.skipif( | |
| "daemon" not in {p.pw_name for p in pwd.getpwall()}, | |
| reason="Issue 50672: nobody is reserved, so a local daemon user is needed", | |
| ) | |
| def test_job_requeue_permission_denied(non_admin): | |
| """A denied requeue reports a per-job error in the status array""" | |
| import openapi_client | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| # Slurm reserves "nobody" and rejects its tokens outright | |
| non_owner = "daemon" | |
| jobid = atf.submit_job_sbatch("--requeue --wrap='sleep infinity' -a1-2", fatal=True) | |
| atf.wait_for_job_state(jobid, "RUNNING", fatal=True) | |
| # Mint a token for an unprivileged user that does not own the job | |
| token = ( | |
| atf.run_command_output( | |
| f"scontrol token username={non_owner}", | |
| user=atf.properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| .strip() | |
| .split("=", 1)[1] | |
| .strip() | |
| ) | |
| config = openapi_client.Configuration() | |
| config.host = atf.properties["slurmrestd_url"] | |
| config.access_token = token | |
| other = openapi_client.SlurmApi(openapi_client.ApiClient(config)) | |
| # A user that does not own the job cannot requeue it, so the denial comes | |
| # back as a per-job error in the status array, not a top-level error | |
| resp = other.slurm_v0046_get_job_requeue(str(jobid)) | |
| assert not resp.errors, "Per-job denial should not report a top-level error" | |
| assert len(resp.status) == 1, "Denied array requeue should report one status" | |
| assert ( | |
| resp.status[0].error_code == 2002 | |
| ), "Denied requeue should report ESLURM_ACCESS_DENIED" | |
| requeue_req = V0046OpenapiJobsRequeueQuery(jobs=[str(jobid)]) | |
| resp = other.slurm_v0046_post_jobs_requeue( | |
| v0046_openapi_jobs_requeue_query=requeue_req, | |
| ) | |
| assert not resp.errors, "Per-job denial should not report a top-level error" | |
| assert len(resp.status) == 1, "Batch requeue should report one status per job" | |
| assert len(resp.status[0]) == 1, "Denied array requeue should report one status" | |
| assert ( | |
| resp.status[0][0].error_code == 2002 | |
| ), "Denied requeue should report ESLURM_ACCESS_DENIED" | |
| # The job was left running, not requeued | |
| assert ( | |
| int(atf.get_job_parameter(jobid, "Restarts", quiet=True)) == 0 | |
| ), "Denied requeue should not requeue the job" | |
| def test_job_requeue_single_task(slurm, non_admin): | |
| """Both requeue endpoints requeue a single array task by its #_# id""" | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| jobid = atf.submit_job_sbatch("--requeue --wrap='sleep infinity' -a1-3", fatal=True) | |
| _get_running_array_tasks(jobid, 3) | |
| # Resolve the raw job ids of every task (stable across the requeue) | |
| task_1, task_2, task_3 = [ | |
| next(iter(atf.get_jobs(job_id=f"{jobid}_{i}", quiet=True))) for i in (1, 2, 3) | |
| ] | |
| # Hold so each requeued task stays PENDING for a deterministic check | |
| resp = slurm.slurm_v0046_get_job_requeue(f"{jobid}_1", hold="True") | |
| assert not resp.errors, "Requeue response should have no errors" | |
| _assert_job_requeue(task_1, "PENDING", "job_requeued_in_held_state", True) | |
| for task_id in (task_2, task_3): | |
| assert ( | |
| atf.get_job_parameter(task_id, "JobState", quiet=True) == "RUNNING" | |
| ), "The other array tasks should keep running" | |
| requeue_req = V0046OpenapiJobsRequeueQuery(jobs=[f"{jobid}_2"], flags=["Hold"]) | |
| resp = slurm.slurm_v0046_post_jobs_requeue( | |
| v0046_openapi_jobs_requeue_query=requeue_req, | |
| ) | |
| assert not resp.errors, "Requeue response should have no errors" | |
| _assert_job_requeue(task_2, "PENDING", "job_requeued_in_held_state", True) | |
| assert ( | |
| atf.get_job_parameter(task_3, "JobState", quiet=True) == "RUNNING" | |
| ), "The other array task should keep running" | |
| def test_job_requeue_non_array(slurm, non_admin): | |
| """A non-array job requeues with an empty status list""" | |
| jobid = atf.submit_job_sbatch("--requeue --wrap='sleep infinity'", fatal=True) | |
| atf.wait_for_job_state(jobid, "RUNNING", fatal=True) | |
| resp = slurm.slurm_v0046_get_job_requeue(str(jobid)) | |
| assert not resp.errors, "Requeue response should have no errors" | |
| # A non-array job does not populate the status list | |
| assert len(resp.status) == 0, "Non-array requeue should have an empty status" | |
| _assert_job_requeue(jobid, "PENDING", "BeginTime", False) | |
| def test_job_requeue_not_requeueable(slurm, non_admin): | |
| """Neither requeue endpoint requeues a job submitted with --no-requeue""" | |
| from openapi_client.models.v0046_openapi_jobs_requeue_query import ( | |
| V0046OpenapiJobsRequeueQuery, | |
| ) | |
| jobid = atf.submit_job_sbatch( | |
| "--no-requeue --wrap='sleep infinity' -a1-2", fatal=True | |
| ) | |
| task_ids = _get_running_array_tasks(jobid, 2) | |
| resp = slurm.slurm_v0046_get_job_requeue(str(jobid)) | |
| assert not resp.errors, "Per-job refusal should not report a top-level error" | |
| assert len(resp.status) == 1, "Refused array requeue should report one status" | |
| assert ( | |
| resp.status[0].error_code == 2037 | |
| ), "Refused requeue should report ESLURM_DISABLED" | |
| requeue_req = V0046OpenapiJobsRequeueQuery(jobs=[str(jobid)]) | |
| resp = slurm.slurm_v0046_post_jobs_requeue( | |
| v0046_openapi_jobs_requeue_query=requeue_req, | |
| ) | |
| assert not resp.errors, "Per-job refusal should not report a top-level error" | |
| assert len(resp.status) == 1, "Batch requeue should report one status per job" | |
| assert len(resp.status[0]) == 1, "Refused array requeue should report one status" | |
| assert ( | |
| resp.status[0][0].error_code == 2037 | |
| ), "Refused requeue should report ESLURM_DISABLED" | |
| for task_id in task_ids: | |
| assert ( | |
| int(atf.get_job_parameter(task_id, "Restarts", quiet=True)) == 0 | |
| ), "A --no-requeue job should never be requeued" | |
| assert ( | |
| atf.get_job_parameter(task_id, "JobState", quiet=True) == "RUNNING" | |
| ), "A --no-requeue job should keep running" | |
| def test_job_stdio_expanded(slurmdb, create_local_user_assoc): | |
| """Verify the slurmdb job record exposes raw and expanded stdio paths.""" | |
| # --expand-patterns is a text-only flag for sacct/squeue; the data parser | |
| # emits the raw and the *_expanded fields unconditionally. | |
| directory = os.getcwd() | |
| name = "expandpat" | |
| stdin_pattern = f"{directory}/restd_%%.in" | |
| atf.run_command(f"touch '{directory}/restd_%.in'", fatal=True) | |
| job_id = atf.submit_job_sbatch( | |
| f"-J {name} -A {account_name} --output={directory}/restd_%j.out" | |
| f" --error={directory}/restd_%x.err --input={stdin_pattern}" | |
| f" --wrap 'true'", | |
| fatal=True, | |
| ) | |
| # The stdio fields are set at submit time, so the job need not run: waiting | |
| # for the record to reach the DB is enough, and keeps this test independent | |
| # of whatever reservations the rest of the module has created. | |
| atf.wait_for_job_accounted(job_id, fatal=True) | |
| resp = slurmdb.slurmdb_v0046_get_job(str(job_id)) | |
| assert not resp.errors, "Getting the job record should not return errors" | |
| assert len(resp.jobs) == 1, f"Expected exactly one record for job {job_id}" | |
| job = resp.jobs[0] | |
| assert job.stdout == f"{directory}/restd_%j.out", "stdout should be unexpanded" | |
| assert job.stderr == f"{directory}/restd_%x.err", "stderr should be unexpanded" | |
| assert job.stdin == stdin_pattern, "stdin should be unexpanded" | |
| assert ( | |
| job.stdout_expanded == f"{directory}/restd_{job_id}.out" | |
| ), "stdout_expanded should replace %j with the job id" | |
| assert ( | |
| job.stderr_expanded == f"{directory}/restd_{name}.err" | |
| ), "stderr_expanded should replace %x with the job name" | |
| assert ( | |
| job.stdin_expanded == f"{directory}/restd_%.in" | |
| ), "stdin_expanded should replace %% with a single percent" | |
| # The account and association can't be removed while the job is active. | |
| atf.run_command(f"scancel {job_id}", fatal=True) | |
| atf.wait_for_job_accounted(job_id, "End", r"^\d", fatal=True) |