跳转至

slurm

feishu.integrations.slurm

SlurmBundle

Slurm 只读状态 bundle,通过配置的 Slurm 客户端查询节点、队列、分区与集群概览。

源代码位于: feishu/integrations/slurm.py
Python
class SlurmBundle:
    r"""Slurm 只读状态 bundle,通过配置的 Slurm 客户端查询节点、队列、分区与集群概览。"""

    def register(self, registry: ToolRegistry, context: BundleContext) -> None:
        registry.add(
            Tool(
                name="get_slurm_cluster_status",
                description="通过配置的 Slurm 客户端读取集群节点、分区与队列的只读状态摘要。",
                input_schema={
                    "type": "object",
                    "properties": {
                        "max_jobs": {"type": "integer", "description": "摘要中最多返回多少个作业样本,默认 20。"},
                        "max_unhealthy_nodes": {
                            "type": "integer",
                            "description": "摘要中最多返回多少个异常节点样本,默认 20。",
                        },
                    },
                    "additionalProperties": False,
                },
                handler=lambda max_jobs=20, max_unhealthy_nodes=20: _get_cluster_status(
                    context, max_jobs=max_jobs, max_unhealthy_nodes=max_unhealthy_nodes
                ),
            )
        )
        registry.add(
            Tool(
                name="list_slurm_nodes",
                description="通过配置的 Slurm 客户端读取节点列表,可按状态做客户端侧过滤。",
                input_schema={
                    "type": "object",
                    "properties": {
                        "state": {"type": "string", "description": "可选节点状态过滤,例如 down、drain、idle。"},
                        "max_results": {"type": "integer", "description": "最多返回多少个节点,默认 100。"},
                    },
                    "additionalProperties": False,
                },
                handler=lambda state=None, max_results=100: _list_nodes(context, state=state, max_results=max_results),
            )
        )
        registry.add(
            Tool(
                name="list_slurm_jobs",
                description="通过配置的 Slurm 客户端读取当前作业列表,可按用户或状态做客户端侧过滤。",
                input_schema={
                    "type": "object",
                    "properties": {
                        "state": {"type": "string", "description": "可选作业状态过滤,例如 running、pending、failed。"},
                        "user": {"type": "string", "description": "可选提交用户过滤。"},
                        "max_results": {"type": "integer", "description": "最多返回多少个作业,默认 50。"},
                    },
                    "additionalProperties": False,
                },
                handler=lambda state=None, user=None, max_results=50: _list_jobs(
                    context, state=state, user=user, max_results=max_results
                ),
            )
        )
        registry.add(
            Tool(
                name="list_slurm_partitions",
                description="通过配置的 Slurm 客户端读取分区列表。",
                input_schema={
                    "type": "object",
                    "properties": {
                        "max_results": {"type": "integer", "description": "最多返回多少个分区,默认 50。"},
                    },
                    "additionalProperties": False,
                },
                handler=lambda max_results=50: _list_partitions(context, max_results=max_results),
            )
        )

SlurmRestdClient

轻量的异步 slurmrestd HTTP 客户端,仅调用 Slurm REST 只读端点。

源代码位于: feishu/integrations/slurm.py
Python
class SlurmRestdClient:
    r"""轻量的异步 slurmrestd HTTP 客户端,仅调用 Slurm REST 只读端点。"""

    def __init__(
        self,
        base_url: str,
        *,
        api_version: str = "v0.0.45",
        user: str | None = None,
        token: str | None = None,
        headers: Mapping[str, str] | None = None,
        timeout: float = 10.0,
        transport: httpx.AsyncBaseTransport | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.api_version = api_version.strip("/")
        self.user = user
        self.token = token
        self.headers = dict(headers or {})
        self.timeout = timeout
        self.transport = transport

    async def cluster_status(self, *, max_jobs: int = 20, max_unhealthy_nodes: int = 20) -> dict[str, Any]:
        ping, nodes, partitions, jobs = await asyncio.gather(
            self.ping(),
            self._collection("nodes"),
            self._collection("partitions"),
            self._collection("jobs"),
        )
        compact_nodes = [_compact_node(node) for node in nodes]
        compact_jobs = [_compact_job(job) for job in jobs]
        return {
            "ping": ping,
            "nodes": {
                "count": len(compact_nodes),
                "by_state": _state_counts(compact_nodes),
                "unhealthy": _limit([node for node in compact_nodes if _is_unhealthy_node(node)], max_unhealthy_nodes),
            },
            "partitions": {
                "count": len(partitions),
                "by_state": _state_counts([_compact_partition(partition) for partition in partitions]),
                "items": _limit([_compact_partition(partition) for partition in partitions], 50),
            },
            "jobs": {
                "count": len(compact_jobs),
                "by_state": _state_counts(compact_jobs),
                "items": _limit(compact_jobs, max_jobs),
            },
        }

    async def ping(self) -> dict[str, Any]:
        payload = await self._request("GET", self._path("ping/"))
        payload_dict = payload if isinstance(payload, dict) else {}
        pings = payload_dict.get("pings")
        if isinstance(pings, list):
            return {"pings": pings}
        return payload_dict

    async def nodes(self, *, state: str | None = None, max_results: int = 100) -> dict[str, Any]:
        nodes = [_compact_node(item) for item in await self._collection("nodes")]
        filtered = [node for node in nodes if _matches_state(node, state)]
        return _limited_collection("nodes", filtered, max_results)

    async def jobs(
        self,
        *,
        state: str | None = None,
        user: str | None = None,
        max_results: int = 50,
    ) -> dict[str, Any]:
        jobs = [_compact_job(item) for item in await self._collection("jobs")]
        filtered = [job for job in jobs if _matches_state(job, state) and _matches_user(job, user)]
        return _limited_collection("jobs", filtered, max_results)

    async def partitions(self, *, max_results: int = 50) -> dict[str, Any]:
        partitions = [_compact_partition(item) for item in await self._collection("partitions")]
        return _limited_collection("partitions", partitions, max_results)

    async def _collection(self, name: str) -> list[dict[str, Any]]:
        payload = await self._request("GET", self._path(f"{name}/"))
        data = payload if isinstance(payload, dict) else {}
        items = data.get(name)
        return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []

    async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
        headers = {"Accept": "application/json", **self.headers}
        if self.user and "X-SLURM-USER-NAME" not in headers:
            headers["X-SLURM-USER-NAME"] = self.user
        if self.token and "X-SLURM-USER-TOKEN" not in headers:
            headers["X-SLURM-USER-TOKEN"] = self.token
        async with httpx.AsyncClient(
            base_url=self.base_url,
            timeout=self.timeout,
            headers=headers,
            transport=self.transport,
        ) as client:
            response = await client.request(method, path, **kwargs)
            response.raise_for_status()
            return response.json()

    def _path(self, suffix: str) -> str:
        return f"/slurm/{self.api_version}/{suffix.lstrip('/')}"

SlurmWebGatewayClient

基于集中式 Slurm-web gateway 的只读 Slurm 客户端。

源代码位于: feishu/integrations/slurm.py
Python
class SlurmWebGatewayClient:
    r"""基于集中式 Slurm-web gateway 的只读 Slurm 客户端。"""

    def __init__(
        self,
        base_url: str,
        *,
        cluster: str | Sequence[str],
        jwt_key_file: str,
        groups: Sequence[str] = (),
        audience: str = "slurm-web",
        token_ttl_seconds: int = 300,
        timeout: float = 10.0,
        transport: httpx.AsyncBaseTransport | None = None,
        username: str | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.clusters = _clusters(cluster)
        self.cluster = self.clusters[0]
        self.jwt_key_file = jwt_key_file
        self.groups = tuple(groups) or tuple(f"cluster.{cluster_name}.user" for cluster_name in self.clusters)
        self.audience = audience
        self.token_ttl_seconds = token_ttl_seconds
        self.timeout = timeout
        self.transport = transport
        self.username = _text(username)

    def with_user(self, username: str) -> SlurmWebGatewayClient:
        return SlurmWebGatewayClient(
            self.base_url,
            cluster=self.clusters,
            jwt_key_file=self.jwt_key_file,
            groups=self.groups,
            audience=self.audience,
            token_ttl_seconds=self.token_ttl_seconds,
            timeout=self.timeout,
            transport=self.transport,
            username=username,
        )

    async def cluster_status(self, *, max_jobs: int = 20, max_unhealthy_nodes: int = 20) -> dict[str, Any]:
        ping, nodes, partitions, jobs = await asyncio.gather(
            self.ping(),
            self.nodes(max_results=10000),
            self.partitions(max_results=10000),
            self.jobs(max_results=10000),
        )
        compact_nodes = nodes["nodes"]
        compact_partitions = partitions["partitions"]
        compact_jobs = jobs["jobs"]
        return {
            "ping": ping,
            "nodes": {
                "count": len(compact_nodes),
                "by_state": _state_counts(compact_nodes),
                "unhealthy": _limit([node for node in compact_nodes if _is_unhealthy_node(node)], max_unhealthy_nodes),
            },
            "partitions": {
                "count": len(compact_partitions),
                "by_state": _state_counts(compact_partitions),
                "items": _limit(compact_partitions, 50),
            },
            "jobs": {
                "count": len(compact_jobs),
                "by_state": _state_counts(compact_jobs),
                "items": _limit(compact_jobs, max_jobs),
            },
        }

    async def ping(self) -> dict[str, Any]:
        payloads = await self._request_all("ping")
        if len(payloads) == 1:
            payload = next(iter(payloads.values()))
            return payload if isinstance(payload, dict) else {"payload": payload}
        return {"clusters": payloads}

    async def nodes(self, *, state: str | None = None, max_results: int = 100) -> dict[str, Any]:
        payloads = await self._request_all("nodes")
        nodes = [
            {**_compact_node(item), "cluster": cluster}
            for cluster, payload in payloads.items()
            for item in _payload_items(payload, "nodes")
        ]
        return _limited_collection("nodes", [node for node in nodes if _matches_state(node, state)], max_results)

    async def jobs(
        self,
        *,
        state: str | None = None,
        user: str | None = None,
        max_results: int = 50,
    ) -> dict[str, Any]:
        requested_user = _text(user) or self._requester_username()
        payloads = await self._request_all("jobs")
        jobs = [
            {**_compact_job(item), "cluster": cluster}
            for cluster, payload in payloads.items()
            for item in _payload_items(payload, "jobs")
        ]
        filtered = [job for job in jobs if _matches_state(job, state) and _matches_user(job, requested_user)]
        return _limited_collection("jobs", filtered, max_results)

    async def partitions(self, *, max_results: int = 50) -> dict[str, Any]:
        payloads = await self._request_all("partitions")
        partitions = [
            {**_compact_partition(item), "cluster": cluster}
            for cluster, payload in payloads.items()
            for item in _payload_items(payload, "partitions")
        ]
        return _limited_collection("partitions", partitions, max_results)

    async def _request_all(self, path: str) -> dict[str, Any]:
        results = await asyncio.gather(*(self._request(cluster, path) for cluster in self.clusters))
        return dict(zip(self.clusters, results, strict=True))

    async def _request(self, cluster: str, path: str) -> Any:
        token = self._token(self._requester_username())
        headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
        url = f"{self.base_url}/api/agents/{cluster}/{path.lstrip('/')}"
        async with httpx.AsyncClient(timeout=self.timeout, headers=headers, transport=self.transport) as client:
            response = await client.get(url)
            response.raise_for_status()
            return response.json()

    def _requester_username(self) -> str:
        return self.username or _requester_username()

    def _token(self, username: str) -> str:
        now = int(time.time())
        key = Path(self.jwt_key_file).read_text(encoding="utf-8").strip()
        return _jwt_hs256(
            key,
            {
                "aud": self.audience,
                "sub": username,
                "groups": list(self.groups),
                "iat": now,
                "exp": now + self.token_ttl_seconds,
            },
        )