From 0c46a1215f0978cfc561e59956023cda700f2698 Mon Sep 17 00:00:00 2001 From: "Jason C. Nucciarone" Date: Tue, 10 Dec 2024 16:59:32 -0500 Subject: [PATCH 1/5] feat(gres): add `gres.conf` data models Changes: * Adds `GRESConfig` for modelling the gres.conf configuration file. * Adds `GRESName` for modelling lines in the gres.conf configuration file. * Adds `GRESNode` for modelling nodes with configured generic resources in gres.conf. Signed-off-by: Jason C. Nucciarone --- slurmutils/models/__init__.py | 3 + slurmutils/models/gres.py | 330 ++++++++++++++++++++++++++++++++++ slurmutils/models/option.py | 31 ++++ 3 files changed, 364 insertions(+) create mode 100644 slurmutils/models/gres.py diff --git a/slurmutils/models/__init__.py b/slurmutils/models/__init__.py index 67dbae2..f43c0e9 100644 --- a/slurmutils/models/__init__.py +++ b/slurmutils/models/__init__.py @@ -16,6 +16,9 @@ from .acctgather import AcctGatherConfig as AcctGatherConfig from .cgroup import CgroupConfig as CgroupConfig +from .gres import GRESConfig as GRESConfig +from .gres import GRESName as GRESName +from .gres import GRESNode as GRESNode from .slurm import DownNodes as DownNodes from .slurm import FrontendNode as FrontendNode from .slurm import Node as Node diff --git a/slurmutils/models/gres.py b/slurmutils/models/gres.py new file mode 100644 index 0000000..142f5f3 --- /dev/null +++ b/slurmutils/models/gres.py @@ -0,0 +1,330 @@ +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Data models for `gres.conf` configuration file.""" + +__all__ = ["GRESConfig", "GRESName", "GRESNode"] + +import copy +from typing import Any + +from .model import BaseModel, clean, marshall_content, parse_line +from .option import GRESConfigOptionSet, GRESNameOptionSet, GRESNodeOptionSet + + +class GRESName(BaseModel): + """`gres.conf` name data model.""" + + def __init__(self, *, Name, **kwargs) -> None: # noqa N803 + super().__init__(GRESNameOptionSet, Name=Name, **kwargs) + + @classmethod + def from_str(cls, content: str) -> "GRESName": + """Construct `GRESName` data model from a gres.conf configuration line.""" + return cls(**parse_line(GRESNameOptionSet, content)) + + def __str__(self) -> str: + """Return `GRESName` data model as a gres.conf configuration line.""" + return " ".join(marshall_content(GRESNameOptionSet, self.data)) + + @property + def auto_detect(self) -> str | None: + """Hardware detection mechanism to enable for automatic GRES configuration. + + Warnings: + * Setting this option will override the configured global automatic + hardware detection mechanism for this generic resource. + """ + return self.data.get("AutoDetect", None) + + @auto_detect.setter + def auto_detect(self, value: str) -> None: + self.data["AutoDetect"] = value + + @auto_detect.deleter + def auto_detect(self) -> None: + try: + del self.data["AutoDetect"] + except KeyError: + pass + + @property + def count(self) -> str | None: + """Number of resources of this name/type available on the node.""" + return self.data.get("Count", None) + + @count.setter + def count(self, value: str) -> None: + self.data["Count"] = value + + @count.deleter + def count(self) -> None: + try: + del self.data["Count"] + except KeyError: + pass + + @property + def cores(self) -> list[str] | None: + """Core index numbers for the specific cores which can use this resource.""" + return self.data.get("Cores", None) + + @cores.setter + def cores(self, value: list[str]) -> None: + self.data["Cores"] = value + + @cores.deleter + def cores(self) -> None: + try: + del self.data["Cores"] + except KeyError: + pass + + @property + def file(self) -> str | None: + """Fully qualified pathname of the device files associated with a resource.""" + return self.data.get("File", None) + + @file.setter + def file(self, value: str) -> None: + self.data["File"] = value + + @file.deleter + def file(self) -> None: + try: + del self.data["File"] + except KeyError: + pass + + @property + def flags(self) -> list[str] | None: + """Flags to change the configured behavior of the generic resource.""" + return self.data.get("Flags", None) + + @flags.setter + def flags(self, value: list[str]) -> None: + self.data["Flags"] = value + + @flags.deleter + def flags(self) -> None: + try: + del self.data["Flags"] + except KeyError: + pass + + @property + def links(self) -> list[str] | None: + """Numbers identifying the number of connections between this device and other devices.""" + return self.data.get("Links", None) + + @links.setter + def links(self, value: list[str]) -> None: + self.data["Links"] = value + + @links.deleter + def links(self) -> None: + try: + del self.data["Links"] + except KeyError: + pass + + @property + def multiple_files(self) -> str | None: + """Fully qualified pathname of the device files associated with a resource. + + Warnings: + * Uses `files` instead if not using GPUs with multi-instance functionality. + * `files` and `multiple_files` cannot be used together. + """ + return self.data.get("MultipleFiles", None) + + @multiple_files.setter + def multiple_files(self, value: str) -> None: + self.data["MultipleFiles"] = value + + @multiple_files.deleter + def multiple_files(self) -> None: + try: + del self.data["MultipleFiles"] + except KeyError: + pass + + @property + def name(self) -> str: + """Name of generic resource.""" + return self.data.get("Name") + + @name.setter + def name(self, value: str) -> None: + self.data["Name"] = value + + @property + def type(self) -> str | None: + """Arbitrary string identifying the type of the generic resource.""" + return self.data.get("Type", None) + + @type.setter + def type(self, value: str) -> None: + self.data["Type"] = value + + @type.deleter + def type(self) -> None: + try: + del self.data["Type"] + except KeyError: + pass + + +class GRESNode(GRESName): + """`gres.conf` node data model.""" + + def __init__(self, *, NodeName: str, **kwargs): # noqa N803 + self.__node_name = NodeName + # Want to share `GRESName` descriptors, but not constructor. + BaseModel.__init__(self, GRESNodeOptionSet, **kwargs) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "GRESNode": + """Construct `GRESNode` data model from dictionary object.""" + node_name = list(data.keys())[0] + return cls(NodeName=node_name, **data[node_name]) + + @classmethod + def from_str(cls, content: str) -> "GRESNode": + """Construct `GRESNode` data model from a gres.conf configuration line.""" + return cls(**parse_line(GRESNodeOptionSet, content)) + + def dict(self) -> dict[str, Any]: + """Return `GRESNode` data model as a dictionary object.""" + return copy.deepcopy({self.__node_name: self.data}) + + def __str__(self) -> str: + """Return `GRESNode` data model as a gres.conf configuration line.""" + line = [f"NodeName={self.__node_name}"] + line.extend(marshall_content(GRESNodeOptionSet, self.data)) + return " ".join(line) + + @property + def node_name(self) -> str: + """Node(s) the generic resource configuration will be applied to. + + Value `NodeName` specification can use a Slurm hostlist specification. + """ + return self.__node_name + + @node_name.setter + def node_name(self, value: str) -> None: + self.__node_name = value + + +class GRESConfig(BaseModel): + """`gres.conf` data model.""" + + def __init__( + self, + *, + Names: list[str] | None = None, # noqa N803 + Nodes: dict[str, Any] | None = None, # noqa N803 + **kwargs, + ) -> None: + super().__init__(GRESConfigOptionSet, **kwargs) + self.data["Names"] = Names or [] + self.data["Nodes"] = Nodes or {} + + @classmethod + def from_str(cls, content: str) -> "GRESConfig": + """Construct `gres.conf` data model from a gres.conf configuration file.""" + data = {} + lines = content.splitlines() + for line in lines: + config = clean(line) + if config is None: + continue + + if config.startswith("Name"): + data["Names"] = data.get("Names", []) + [GRESName.from_str(config).dict()] + elif config.startswith("NodeName"): + nodes = data.get("Nodes", {}) + nodes.update(GRESNode.from_str(config).dict()) + data["Nodes"] = nodes + else: + data.update(parse_line(GRESConfigOptionSet, config)) + + return GRESConfig.from_dict(data) + + def __str__(self) -> str: + """Return `gres.conf` data model in gres.conf format.""" + data = self.dict() + global_auto_detect = data.pop("AutoDetect", None) + names = data.pop("Names", []) + nodes = data.pop("Nodes", {}) + + content = [] + if global_auto_detect: + content.append(f"AutoDetect={global_auto_detect}") + if names: + content.extend([str(GRESName(**name)) for name in names]) + if nodes: + content.extend([str(GRESNode(NodeName=k, **v)) for k, v in nodes.items()]) + + return "\n".join(content) + "\n" + + @property + def auto_detect(self) -> str | None: + """Get global `AutoDetect` configuration in `gres.conf`. + + Warnings: + * Setting this option will configure the automatic hardware detection mechanism + globally within `gres.conf`. Inline `AutoDetect` can be set used on + `GRESNode` and`GRESName` to override the global automatic hardware + detection mechanism for specific nodes or resource names. + """ + return self.data.get("AutoDetect", None) + + @auto_detect.setter + def auto_detect(self, value: str) -> None: + self.data["AutoDetect"] = value + + @auto_detect.deleter + def auto_detect(self) -> None: + try: + del self.data["AutoDetect"] + except KeyError: + pass + + @property + def names(self) -> list[dict[str, Any]] | None: + """List of configured generic resources.""" + return self.data.get("Names", None) + + @names.setter + def names(self, value: list[dict[str, Any]]) -> None: + self.data["Names"] = value + + @names.deleter + def names(self) -> None: + self.data["Names"] = [] + + @property + def nodes(self) -> dict[str, dict[str, Any]]: + """Map of nodes with configured generic resources.""" + return self.data["Nodes"] + + @nodes.setter + def nodes(self, value: dict[str, GRESNode]) -> None: + self.data["Nodes"] = value + + @nodes.deleter + def nodes(self) -> None: + self.data["Nodes"] = {} diff --git a/slurmutils/models/option.py b/slurmutils/models/option.py index 7ca6611..a01ac86 100644 --- a/slurmutils/models/option.py +++ b/slurmutils/models/option.py @@ -17,6 +17,9 @@ __all__ = [ "AcctGatherConfigOptionSet", "CgroupConfigOptionSet", + "GRESConfigOptionSet", + "GRESNameOptionSet", + "GRESNodeOptionSet", "SlurmdbdConfigOptionSet", "SlurmConfigOptionSet", "NodeOptionSet", @@ -97,6 +100,34 @@ class CgroupConfigOptionSet(_OptionSet): SignalChildrenProcesses: Callback = Callback() +@dataclass(frozen=True) +class GRESConfigOptionSet(_OptionSet): + """`gres.conf` configuration options.""" + + AutoDetect: Callback = Callback() + + +@dataclass(frozen=True) +class GRESNameOptionSet(GRESConfigOptionSet): + """`gres.conf` generic configuration options.""" + + Count: Callback = Callback() + Cores: Callback = CommaSeparatorCallback + File: Callback = Callback() + Flags: Callback = CommaSeparatorCallback + Links: Callback = CommaSeparatorCallback + MultipleFiles: Callback = Callback() + Name: Callback = Callback() + Type: Callback = Callback() + + +@dataclass(frozen=True) +class GRESNodeOptionSet(GRESNameOptionSet): + """`gres.conf` node configuration options.""" + + NodeName: Callback = Callback() + + @dataclass(frozen=True) class SlurmdbdConfigOptionSet(_OptionSet): """`slurmdbd.conf` configuration options.""" From a8a4f12578a720d10af59c414fb6bc8eec3aaf46 Mon Sep 17 00:00:00 2001 From: "Jason C. Nucciarone" Date: Tue, 10 Dec 2024 17:01:46 -0500 Subject: [PATCH 2/5] feat(gres): add `gresconfig` configuration file editor Signed-off-by: Jason C. Nucciarone --- slurmutils/editors/__init__.py | 1 + slurmutils/editors/gresconfig.py | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 slurmutils/editors/gresconfig.py diff --git a/slurmutils/editors/__init__.py b/slurmutils/editors/__init__.py index 9a4a0f1..40ba550 100644 --- a/slurmutils/editors/__init__.py +++ b/slurmutils/editors/__init__.py @@ -16,5 +16,6 @@ from . import acctgatherconfig as acctgatherconfig from . import cgroupconfig as cgroupconfig +from . import gresconfig as gresconfig from . import slurmconfig as slurmconfig from . import slurmdbdconfig as slurmdbdconfig diff --git a/slurmutils/editors/gresconfig.py b/slurmutils/editors/gresconfig.py new file mode 100644 index 0000000..62b20db --- /dev/null +++ b/slurmutils/editors/gresconfig.py @@ -0,0 +1,82 @@ +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Edit gres.conf files.""" + +__all__ = ["dump", "dumps", "load", "loads", "edit"] + +import logging +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Optional, Union + +from ..models import GRESConfig +from .editor import dumper, loader, set_file_permissions + +_logger = logging.getLogger("slurmutils") + + +@loader +def load(file: Union[str, os.PathLike]) -> GRESConfig: + """Load `gres.conf` data model from gres.conf file.""" + return loads(Path(file).read_text()) + + +def loads(content: str) -> GRESConfig: + """Load `gres.conf` data model from string.""" + return GRESConfig.from_str(content) + + +@dumper +def dump( + config: GRESConfig, + file: Union[str, os.PathLike], + mode: int = 0o644, + user: Optional[Union[str, int]] = None, + group: Optional[Union[str, int]] = None, +) -> None: + """Dump `gres.conf` data model into gres.conf file.""" + Path(file).write_text(dumps(config)) + set_file_permissions(file, mode, user, group) + + +def dumps(config: GRESConfig) -> str: + """Dump `gres.conf` data model into a string.""" + return str(config) + + +@contextmanager +def edit( + file: Union[str, os.PathLike], + mode: int = 0o644, + user: Optional[Union[str, int]] = None, + group: Optional[Union[str, int]] = None, +) -> GRESConfig: + """Edit a gres.conf file. + + Args: + file: gres.conf file to edit. An empty config will be created if it does not exist. + mode: Access mode to apply to the gres.conf file. (Default: rw-r--r--) + user: User to set as owner of the gres.conf file. (Default: $USER) + group: Group to set as owner of the gres.conf file. (Default: None) + """ + if not os.path.exists(file): + _logger.warning("file %s not found. creating new empty gres.conf configuration", file) + config = GRESConfig() + else: + config = load(file) + + yield config + dump(config, file, mode, user, group) From aafbc4bea830ff84fe146ce00e52a8a33c1e7154 Mon Sep 17 00:00:00 2001 From: "Jason C. Nucciarone" Date: Tue, 10 Dec 2024 17:02:22 -0500 Subject: [PATCH 3/5] tests(gres): add unit tests for gres.conf editor and data models Signed-off-by: Jason C. Nucciarone --- tests/unit/editors/constants.py | 17 +++ tests/unit/editors/test_gresconfig.py | 103 +++++++++++++++++ tests/unit/models/test_gres.py | 156 ++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 tests/unit/editors/test_gresconfig.py create mode 100644 tests/unit/models/test_gres.py diff --git a/tests/unit/editors/constants.py b/tests/unit/editors/constants.py index c534fb6..caa3403 100644 --- a/tests/unit/editors/constants.py +++ b/tests/unit/editors/constants.py @@ -12,6 +12,23 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +EXAMPLE_GRES_CONFIG = """# +# `gres.conf` file generated at 2024-12-10 14:17:35.161642 by slurmutils. +# +AutoDetect=nvml +Name=gpu Type=gp100 File=/dev/nvidia0 Cores=0,1 +Name=gpu Type=gp100 File=/dev/nvidia1 Cores=0,1 +Name=gpu Type=p6000 File=/dev/nvidia2 Cores=2,3 +Name=gpu Type=p6000 File=/dev/nvidia3 Cores=2,3 +Name=mps Count=200 File=/dev/nvidia0 +Name=mps Count=200 File=/dev/nvidia1 +Name=mps Count=100 File=/dev/nvidia2 +Name=mps Count=100 File=/dev/nvidia3 +Name=bandwidth Type=lustre Count=4G Flags=CountOnly + +NodeName=juju-c9c6f-[1-10] Name=gpu Type=rtx File=/dev/nvidia[0-3] Count=8G +""" + EXAMPLE_SLURM_CONFIG = """# # `slurm.conf` file generated at 2024-01-30 17:18:36.171652 by slurmutils. # diff --git a/tests/unit/editors/test_gresconfig.py b/tests/unit/editors/test_gresconfig.py new file mode 100644 index 0000000..f9513d4 --- /dev/null +++ b/tests/unit/editors/test_gresconfig.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from constants import EXAMPLE_GRES_CONFIG +from pyfakefs.fake_filesystem_unittest import TestCase + +from slurmutils.editors import gresconfig +from slurmutils.models import GRESName, GRESNode + + +class TestGRESConfigEditor(TestCase): + """Unit tests for gres.conf configuration file editor.""" + + def setUp(self) -> None: + self.setUpPyfakefs() + self.fs.create_file("/etc/slurm/gres.conf", contents=EXAMPLE_GRES_CONFIG) + + def test_loads(self) -> None: + """Test `loads` function from the `gresconfig` editor module.""" + config = gresconfig.loads(EXAMPLE_GRES_CONFIG) + self.assertEqual(config.auto_detect, "nvml") + self.assertListEqual( + config.names, + [ + {"Name": "gpu", "Type": "gp100", "File": "/dev/nvidia0", "Cores": ["0", "1"]}, + {"Name": "gpu", "Type": "gp100", "File": "/dev/nvidia1", "Cores": ["0", "1"]}, + {"Name": "gpu", "Type": "p6000", "File": "/dev/nvidia2", "Cores": ["2", "3"]}, + {"Name": "gpu", "Type": "p6000", "File": "/dev/nvidia3", "Cores": ["2", "3"]}, + {"Name": "mps", "Count": "200", "File": "/dev/nvidia0"}, + {"Name": "mps", "Count": "200", "File": "/dev/nvidia1"}, + {"Name": "mps", "Count": "100", "File": "/dev/nvidia2"}, + {"Name": "mps", "Count": "100", "File": "/dev/nvidia3"}, + {"Name": "bandwidth", "Type": "lustre", "Count": "4G", "Flags": ["CountOnly"]}, + ], + ) + self.assertDictEqual( + config.nodes, + { + "juju-c9c6f-[1-10]": { + "Name": "gpu", + "Type": "rtx", + "File": "/dev/nvidia[0-3]", + "Count": "8G", + } + }, + ) + + def test_dumps(self) -> None: + """Test `dumps` function from the `gresconfig` editor module.""" + config = gresconfig.loads(EXAMPLE_GRES_CONFIG) + # New `gres.conf` will be different since the comments have been + # stripped out by the editor. + self.assertNotEqual(gresconfig.dumps(config), EXAMPLE_GRES_CONFIG) + + def test_edit(self) -> None: + """Test `edit` context manager from the `gresconfig` editor module.""" + name = GRESName( + Name="gpu", + Type="epyc", + File="/dev/amd4", + Cores=["0", "1"], + ) + node = GRESNode( + NodeName="juju-abc654-[1-20]", + Name="gpu", + Type="epyc", + File="/dev/amd[0-3]", + Count="12G", + ) + + # Set new values with each accessor. + with gresconfig.edit("/etc/slurm/gres.conf") as config: + config.auto_detect = "rsmi" + config.names = [name.dict()] + config.nodes = node.dict() + + config = gresconfig.load("/etc/slurm/gres.conf") + self.assertEqual(config.auto_detect, "rsmi") + self.assertListEqual(config.names, [name.dict()]) + self.assertDictEqual(config.nodes, node.dict()) + + # Delete all configured values from GRES configuration. + with gresconfig.edit("/etc/slurm/gres.conf") as config: + del config.auto_detect + del config.names + del config.nodes + + config = gresconfig.load("/etc/slurm/gres.conf") + self.assertIsNone(config.auto_detect) + self.assertListEqual(config.names, []) + self.assertDictEqual(config.nodes, {}) diff --git a/tests/unit/models/test_gres.py b/tests/unit/models/test_gres.py new file mode 100644 index 0000000..3cf72ab --- /dev/null +++ b/tests/unit/models/test_gres.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from unittest import TestCase + +from slurmutils.models import GRESConfig, GRESName, GRESNode + + +class TestGRESConfig(TestCase): + """Unit tests for `gres.conf` data model.""" + + def setUp(self) -> None: + self.config = GRESConfig() + self.names = GRESName.from_dict( + {"Name": "gpu", "Type": "gp100", "File": "/dev/nvidia0", "Cores": ["0", "1"]} + ) + self.nodes = GRESNode.from_dict( + { + "juju-c9c6f-[1-10]": { + "Name": "gpu", + "Type": "rtx", + "File": "/dev/nvidia[0-3]", + "Count": "8G", + } + } + ) + + def test_auto_detect(self) -> None: + """Test global `AutoDetect` descriptor.""" + del self.config.auto_detect + self.config.auto_detect = "rsmi" + self.assertEqual(self.config.auto_detect, "rsmi") + del self.config.auto_detect + self.assertIsNone(self.config.auto_detect) + + def test_names(self) -> None: + """Test `Names` descriptor.""" + self.config.names = [self.names.dict()] + self.assertListEqual(self.config.names, [self.names.dict()]) + del self.config.names + self.assertListEqual(self.config.names, []) + + def test_nodes(self) -> None: + """Test `Nodes` descriptor.""" + self.config.nodes = self.nodes.dict() + self.assertDictEqual(self.config.nodes, self.nodes.dict()) + del self.config.nodes + self.assertDictEqual(self.config.nodes, {}) + + +class TestGRESName(TestCase): + """Unit tests for `GRESName` data model.""" + + def setUp(self) -> None: + self.config = GRESName(Name="gpu") + + def test_auto_detect(self) -> None: + """Test in-line `AutoDetect` descriptor.""" + del self.config.auto_detect + self.config.auto_detect = "rsmi" + self.assertEqual(self.config.auto_detect, "rsmi") + del self.config.auto_detect + self.assertIsNone(self.config.auto_detect) + + def test_count(self) -> None: + """Test `Count` descriptor.""" + del self.config.count + self.config.count = "10G" + self.assertEqual(self.config.count, "10G") + del self.config.count + self.assertIsNone(self.config.count) + + def test_cores(self) -> None: + """Test `Cores` descriptor.""" + del self.config.cores + self.config.cores = ["0", "1"] + self.assertListEqual(self.config.cores, ["0", "1"]) + del self.config.cores + self.assertIsNone(self.config.cores) + + def test_file(self) -> None: + """Test `File` descriptor.""" + del self.config.file + self.config.file = "/dev/amd[0-4]" + self.assertEqual(self.config.file, "/dev/amd[0-4]") + del self.config.file + self.assertIsNone(self.config.file) + + def test_flags(self) -> None: + """Test `Flags` descriptor.""" + del self.config.flags + self.config.flags = ["CountOnly", "amd_gpu_env"] + self.assertListEqual(self.config.flags, ["CountOnly", "amd_gpu_env"]) + del self.config.flags + self.assertIsNone(self.config.flags) + + def test_links(self) -> None: + """Test `Links` descriptor.""" + del self.config.links + self.config.links = ["-1", "16", "16", "16"] + self.assertListEqual(self.config.links, ["-1", "16", "16", "16"]) + del self.config.links + self.assertIsNone(self.config.links) + + def test_multiple_files(self) -> None: + """Test `MultipleFiles` descriptor.""" + del self.config.multiple_files + self.config.multiple_files = "/dev/amd[0-4]" + self.assertEqual(self.config.multiple_files, "/dev/amd[0-4]") + del self.config.multiple_files + self.assertIsNone(self.config.multiple_files) + + def test_name(self) -> None: + """Test `Name` descriptor.""" + self.assertEqual(self.config.name, "gpu") + self.config.name = "shard" + self.assertEqual(self.config.name, "shard") + # Ensure that `Name` cannot be deleted. + with self.assertRaises(AttributeError): + del self.config.name # noqa + + def test_type(self) -> None: + """Test `Type` descriptor.""" + del self.config.type + self.config.type = "epyc" + self.assertEqual(self.config.type, "epyc") + del self.config.type + self.assertIsNone(self.config.type) + + +class TestGRESNode(TestCase): + """Unit tests for `GRESNode` data model.""" + + def setUp(self) -> None: + self.config = GRESNode(NodeName="juju-c9c6f-[1-10]") + + def test_node_name(self) -> None: + """Test `NodeName` descriptor.""" + self.assertEqual(self.config.node_name, "juju-c9c6f-[1-10]") + self.config.node_name = "juju-c9c6f-[1-5]" + self.assertEqual(self.config.node_name, "juju-c9c6f-[1-5]") + # Ensure that `NodeName` cannot be deleted. + with self.assertRaises(AttributeError): + del self.config.node_name # noqa From 466ca6357b8402087853c7bb2daaf168c090250e Mon Sep 17 00:00:00 2001 From: "Jason C. Nucciarone" Date: Tue, 10 Dec 2024 17:07:46 -0500 Subject: [PATCH 4/5] docs(gres): add info on how to use gres editor to README Signed-off-by: Jason C. Nucciarone --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 56b89b7..3c12db7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ slurmutils package include: #### `from slurmutils.editors import ...` * `acctgatherconfig`: An editor for _acct_gather.conf_ configuration files. +* `gresconfig`: An editor for _gres.conf_ configuration files. * `cgroupconfig`: An editor for _cgroup.conf_ configuration files. * `slurmconfig`: An editor for _slurm.conf_ configuration files. * `slurmdbdconfig`: An editor for _slurmdbd.conf_ configuration files. @@ -84,6 +85,33 @@ with cgroupconfig.edit("/etc/slurm/cgroup.conf") as config: config.constrain_swap_space = "yes" ``` +##### `gresconfig` + +###### Edit a pre-existing _gres.conf_ configuration file + +```python +from slurmutils.editors import gresconfig +from slurmutils.models import GRESName, GRESNode + +with gresconfig.edit("/etc/slurm/gres.conf") as config: + name = GRESName( + Name="gpu", + Type="epyc", + File="/dev/amd4", + Cores=["0", "1"], + ) + node = GRESNode( + NodeName="juju-abc654-[1-20]", + Name="gpu", + Type="epyc", + File="/dev/amd[0-3]", + Count="12G", + ) + config.auto_detect = "rsmi" + config.names.append(name.dict()) + config.nodes.updaten(node.dict()) +``` + ##### `slurmconfig` ###### Edit a pre-existing _slurm.conf_ configuration file From 0f20a0a062ed7d864bec947dfd323f58c45e19ce Mon Sep 17 00:00:00 2001 From: "Jason C. Nucciarone" Date: Tue, 10 Dec 2024 17:10:55 -0500 Subject: [PATCH 5/5] chore(release): bump slurmutils to 0.10.0 Release adds new `gres.conf` editor. Also tests out explicitly defining descriptors for static typing tools such as `pyright` or `mypy` Signed-off-by: Jason C. Nucciarone --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e777aab..4f81dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "slurmutils" -version = "0.9.0" +version = "0.10.0" description = "Utilities and APIs for interfacing with the Slurm workload manager." repository = "https://github.com/charmed-hpc/slurmutils" authors = ["Jason C. Nucciarone "]