node804-hpe-msa 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hpe_msa_mcp/__init__.py +14 -0
- hpe_msa_mcp/__main__.py +20 -0
- hpe_msa_mcp/_redact.py +75 -0
- hpe_msa_mcp/client.py +395 -0
- hpe_msa_mcp/config/__init__.py +0 -0
- hpe_msa_mcp/config/arrays.py +591 -0
- hpe_msa_mcp/py.typed +0 -0
- hpe_msa_mcp/responses/__init__.py +0 -0
- hpe_msa_mcp/responses/shapers.py +595 -0
- hpe_msa_mcp/server.py +123 -0
- hpe_msa_mcp/setup.py +781 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/SKILL.md +97 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/events-alerts.md +43 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/health-model.md +54 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/hosts-mapping.md +60 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/capacity-review.md +36 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/disk-failure-triage.md +42 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/event-triage.md +37 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/firmware-inventory.md +30 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/health-check.md +38 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/mapping-audit.md +39 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/performance-review.md +41 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/statistics.md +55 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/storage-model.md +64 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/references/tool-map.md +86 -0
- hpe_msa_mcp/skills/hpe-msa-engineer/scripts/README.md +5 -0
- hpe_msa_mcp/tools/__init__.py +0 -0
- hpe_msa_mcp/tools/_common.py +191 -0
- hpe_msa_mcp/tools/aggregations.py +152 -0
- hpe_msa_mcp/tools/events.py +106 -0
- hpe_msa_mcp/tools/hardware.py +160 -0
- hpe_msa_mcp/tools/hosts.py +138 -0
- hpe_msa_mcp/tools/statistics.py +88 -0
- hpe_msa_mcp/tools/status.py +76 -0
- hpe_msa_mcp/tools/storage.py +118 -0
- hpe_msa_mcp/tools/system.py +190 -0
- hpe_msa_mcp/tools/utility.py +143 -0
- node804_hpe_msa-0.1.0.dist-info/METADATA +461 -0
- node804_hpe_msa-0.1.0.dist-info/RECORD +42 -0
- node804_hpe_msa-0.1.0.dist-info/WHEEL +4 -0
- node804_hpe_msa-0.1.0.dist-info/entry_points.txt +3 -0
- node804_hpe_msa-0.1.0.dist-info/licenses/LICENSE +21 -0
hpe_msa_mcp/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Token-efficient, read-only MCP server for HPE MSA (Modular Smart Array) storage.
|
|
2
|
+
|
|
3
|
+
Designed around three principles:
|
|
4
|
+
1. Lean responses by default — typical 40-70% token reduction vs unfiltered output.
|
|
5
|
+
2. Default-deny RBAC — only read tools are exposed; MSA_MODE never registers writes.
|
|
6
|
+
3. TLS verification on by default — MSA controller certs are often self-signed,
|
|
7
|
+
so disabling verification requires an explicit opt-in.
|
|
8
|
+
|
|
9
|
+
See the README for installation, configuration, and the full tool catalog.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
hpe_msa_mcp/__main__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Entry point for ``python -m hpe_msa_mcp``.
|
|
2
|
+
|
|
3
|
+
This file makes the package runnable as a module — useful when an MCP host
|
|
4
|
+
config prefers to specify a Python interpreter explicitly rather than rely
|
|
5
|
+
on the ``node804-hpe-msa`` console script being on the user's PATH.
|
|
6
|
+
|
|
7
|
+
Both invocations call the same ``main()`` function:
|
|
8
|
+
|
|
9
|
+
node804-hpe-msa # via [project.scripts] console script
|
|
10
|
+
python -m hpe_msa_mcp # via this file
|
|
11
|
+
|
|
12
|
+
Pick whichever is easier to configure in your MCP host.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .server import main
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
hpe_msa_mcp/_redact.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Secret-redaction helpers for error messages and log lines.
|
|
2
|
+
|
|
3
|
+
The HPE MSA REST API authenticates in two stages that both put secrets into
|
|
4
|
+
request URLs and headers:
|
|
5
|
+
|
|
6
|
+
1. Login hashes ``username_password`` and sends it as a URL path segment
|
|
7
|
+
(``GET /api/login/<hash>``). httpx and the underlying transport can echo
|
|
8
|
+
that full URL into exception strings on a TLS or transport failure, so
|
|
9
|
+
the login hash (from which the password can be brute-forced offline) can
|
|
10
|
+
leak into logs.
|
|
11
|
+
2. Every subsequent call carries a ``sessionKey`` header. Some error paths
|
|
12
|
+
stringify the request (including headers), surfacing the live session key.
|
|
13
|
+
|
|
14
|
+
This module scrubs both from arbitrary text. Every code path that turns an
|
|
15
|
+
exception into a log line or a tool response runs the result through here.
|
|
16
|
+
|
|
17
|
+
The redaction is intentionally conservative — regex-only, so there is no
|
|
18
|
+
parsing surface to abuse, and it preserves surrounding context so log readers
|
|
19
|
+
can still see where the event happened.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from typing import Final
|
|
26
|
+
|
|
27
|
+
REDACTED: Final = "***REDACTED***"
|
|
28
|
+
|
|
29
|
+
# ``/api/login/<hash>`` — the hash is a hex SHA-256 (64 chars) or hex MD5 (32
|
|
30
|
+
# chars). Match a generous hex run after the login path so either firmware's
|
|
31
|
+
# hash is scrubbed, value only, path structure preserved.
|
|
32
|
+
_LOGIN_HASH_RE: Final = re.compile(
|
|
33
|
+
r"(?P<prefix>/api/login/)(?P<val>[0-9a-fA-F]{16,})",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ``sessionKey`` however it is spelled in a header dump or repr:
|
|
37
|
+
# ``sessionKey: ABC...``, ``'sessionKey': 'ABC...'``, ``sessionKey=ABC...``.
|
|
38
|
+
_SESSION_KEY_RE: Final = re.compile(
|
|
39
|
+
r"(?P<name>sessionKey)(?P<sep>['\"]?\s*[:=]\s*['\"]?)(?P<val>[A-Za-z0-9+/=_\-]{8,})",
|
|
40
|
+
flags=re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Bare ``password=...`` / ``password: ...`` assignments that can appear in a
|
|
44
|
+
# config repr or a stray debug line. Defense in depth — the password should
|
|
45
|
+
# never reach a log, but if it does, scrub it.
|
|
46
|
+
_PASSWORD_RE: Final = re.compile(
|
|
47
|
+
r"(?P<name>password)(?P<sep>['\"]?\s*[:=]\s*['\"]?)(?P<val>[^\s'\"<>&]+)",
|
|
48
|
+
flags=re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def redact_secrets(text: str) -> str:
|
|
53
|
+
"""Strip login hashes, session keys, and passwords from a string.
|
|
54
|
+
|
|
55
|
+
Safe to call on any string — if no secret patterns match, the input is
|
|
56
|
+
returned unchanged. Cheap enough to apply liberally on every error path.
|
|
57
|
+
"""
|
|
58
|
+
if not text:
|
|
59
|
+
return text
|
|
60
|
+
text = _LOGIN_HASH_RE.sub(lambda m: f"{m.group('prefix')}{REDACTED}", text)
|
|
61
|
+
text = _SESSION_KEY_RE.sub(lambda m: f"{m.group('name')}{m.group('sep')}{REDACTED}", text)
|
|
62
|
+
text = _PASSWORD_RE.sub(lambda m: f"{m.group('name')}{m.group('sep')}{REDACTED}", text)
|
|
63
|
+
return text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def redact_exception(exc: BaseException) -> str:
|
|
67
|
+
"""Render an exception as a redacted string suitable for logs/responses.
|
|
68
|
+
|
|
69
|
+
Falls back to the class name when ``str(exc)`` is empty, so a bare
|
|
70
|
+
exception type still produces a useful message.
|
|
71
|
+
"""
|
|
72
|
+
return redact_secrets(str(exc)) or exc.__class__.__name__
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = ["REDACTED", "redact_exception", "redact_secrets"]
|
hpe_msa_mcp/client.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""HTTP client for the HPE MSA REST management API, with session caching.
|
|
2
|
+
|
|
3
|
+
Unlike the SDK-backed servers in the suite (panos wraps ``pan-os-python``),
|
|
4
|
+
HPE MSA ships no Python SDK, so this module talks to the array's REST API
|
|
5
|
+
directly over ``httpx``.
|
|
6
|
+
|
|
7
|
+
MSA auth is a **session key**, not a long-lived API key:
|
|
8
|
+
|
|
9
|
+
1. Hash ``"<username>_<password>"`` (SHA-256, MD5 fallback for old firmware).
|
|
10
|
+
2. ``GET /api/login/<hash>`` returns a ``sessionKey`` in its status object.
|
|
11
|
+
3. Every subsequent call sends that key as the ``sessionKey`` header.
|
|
12
|
+
4. The key idles out (~30 min); an expired-session response triggers one
|
|
13
|
+
transparent re-login + retry.
|
|
14
|
+
|
|
15
|
+
So this client owns login, session-key caching, and re-login-on-expiry.
|
|
16
|
+
:class:`MSAClient` instances are cached for the server-process lifetime, keyed
|
|
17
|
+
by array name, so a session logs in once per array and reuses the key.
|
|
18
|
+
|
|
19
|
+
Errors are returned as :class:`ResolveError` (``.message``) rather than raised,
|
|
20
|
+
so tools surface a clean string to the model instead of a stack trace. Every
|
|
21
|
+
error string passes through :func:`redact_exception` so a leaked login hash or
|
|
22
|
+
session key never reaches a log.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import hashlib
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
from node804_mcp_toolkit import TlsConfig
|
|
35
|
+
|
|
36
|
+
from ._redact import redact_exception, redact_secrets
|
|
37
|
+
from .config.arrays import ArrayEntry, resolve_array
|
|
38
|
+
|
|
39
|
+
# A single stuck request should fail fast rather than hang the whole MCP
|
|
40
|
+
# process — every read tool calls the array synchronously, so an unbounded
|
|
41
|
+
# request blocks even local tools like server_status. Bounded per request,
|
|
42
|
+
# overridable via MSA_API_TIMEOUT.
|
|
43
|
+
_DEFAULT_API_TIMEOUT = 30
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _api_timeout() -> float:
|
|
47
|
+
"""Per-request API timeout in seconds (override via ``MSA_API_TIMEOUT``)."""
|
|
48
|
+
raw = os.environ.get("MSA_API_TIMEOUT")
|
|
49
|
+
if raw:
|
|
50
|
+
try:
|
|
51
|
+
value = float(raw)
|
|
52
|
+
if value > 0:
|
|
53
|
+
return value
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
return _DEFAULT_API_TIMEOUT
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ResolveError:
|
|
61
|
+
"""Returned when a request can't be completed (resolve/login/transport).
|
|
62
|
+
|
|
63
|
+
Tools surface ``ResolveError.message`` to the model rather than raising,
|
|
64
|
+
so the caller gets a clean, already-redacted error string.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
message: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# MSA marks an expired/invalid session with this return code; the response
|
|
71
|
+
# also comes back as HTTP 401 on most firmware. Either triggers one re-login.
|
|
72
|
+
_SESSION_EXPIRED_CODES = frozenset({-10005, -10000})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_verify(tls: TlsConfig) -> bool | str:
|
|
76
|
+
"""Translate our TLS config into httpx's ``verify`` argument."""
|
|
77
|
+
if not tls.verify:
|
|
78
|
+
return False
|
|
79
|
+
if tls.ca_path is not None and tls.ca_bundle is not None:
|
|
80
|
+
return str(tls.ca_path)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_status(payload: Any) -> dict[str, Any]:
|
|
85
|
+
"""Return the trailing ``status`` object from an MSA response, or ``{}``.
|
|
86
|
+
|
|
87
|
+
Every MSA response ends with a ``status`` array describing the command
|
|
88
|
+
result; the last element is the authoritative one.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
return {}
|
|
92
|
+
status = payload.get("status")
|
|
93
|
+
if isinstance(status, list) and status:
|
|
94
|
+
last = status[-1]
|
|
95
|
+
return last if isinstance(last, dict) else {}
|
|
96
|
+
if isinstance(status, dict):
|
|
97
|
+
return status
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _status_is_error(status: dict[str, Any]) -> bool:
|
|
102
|
+
"""True when a status object reports a command failure."""
|
|
103
|
+
rtype = str(status.get("response-type", "")).strip().lower()
|
|
104
|
+
if rtype == "error":
|
|
105
|
+
return True
|
|
106
|
+
# Some firmware omits response-type but sets a negative return-code.
|
|
107
|
+
code = status.get("return-code")
|
|
108
|
+
return isinstance(code, int) and code < 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _status_is_expired(status: dict[str, Any]) -> bool:
|
|
112
|
+
"""True when a status object indicates an expired/invalid session."""
|
|
113
|
+
code = status.get("return-code")
|
|
114
|
+
if isinstance(code, int) and code in _SESSION_EXPIRED_CODES:
|
|
115
|
+
return True
|
|
116
|
+
response = str(status.get("response", "")).lower()
|
|
117
|
+
return "session" in response and ("expired" in response or "invalid" in response)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class MSAClient:
|
|
122
|
+
"""A logged-in connection to one MSA array, cached for the process lifetime.
|
|
123
|
+
|
|
124
|
+
``hosts`` is the failover-ordered list of management addresses for the one
|
|
125
|
+
array (a VIP, or the two controller IPs). Login walks the list and settles
|
|
126
|
+
on the first host that answers; that host is then used for every subsequent
|
|
127
|
+
request until the session expires, at which point failover runs again.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
name: str
|
|
131
|
+
hosts: list[str]
|
|
132
|
+
username: str
|
|
133
|
+
password: str
|
|
134
|
+
tls: TlsConfig
|
|
135
|
+
timeout: float = field(default_factory=_api_timeout)
|
|
136
|
+
_active_index: int = field(default=0, init=False, repr=False)
|
|
137
|
+
_session_key: str | None = field(default=None, init=False, repr=False)
|
|
138
|
+
_http: httpx.Client | None = field(default=None, init=False, repr=False)
|
|
139
|
+
|
|
140
|
+
# --- HTTP plumbing ------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def host(self) -> str:
|
|
144
|
+
"""The management address currently in use (the active failover host)."""
|
|
145
|
+
return self.hosts[self._active_index]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def base_url(self) -> str:
|
|
149
|
+
return f"https://{self.host}/api"
|
|
150
|
+
|
|
151
|
+
def _http_client(self) -> httpx.Client:
|
|
152
|
+
if self._http is None or self._http.is_closed:
|
|
153
|
+
self._http = httpx.Client(
|
|
154
|
+
base_url=self.base_url,
|
|
155
|
+
verify=_build_verify(self.tls),
|
|
156
|
+
timeout=self.timeout,
|
|
157
|
+
headers={"datatype": "json"},
|
|
158
|
+
)
|
|
159
|
+
return self._http
|
|
160
|
+
|
|
161
|
+
def _activate_host(self, index: int) -> None:
|
|
162
|
+
"""Switch the active host, rebuilding the HTTP client against its base URL."""
|
|
163
|
+
if index != self._active_index and self._http is not None and not self._http.is_closed:
|
|
164
|
+
self._http.close()
|
|
165
|
+
self._http = None
|
|
166
|
+
self._active_index = index
|
|
167
|
+
|
|
168
|
+
def _login_hashes(self) -> list[str]:
|
|
169
|
+
"""Login hashes to try, in order: SHA-256 first, MD5 fallback.
|
|
170
|
+
|
|
171
|
+
Gen5/Gen6 support SHA-256; only much older firmware is MD5-only. We try
|
|
172
|
+
the strong hash first and fall back so a mixed/old array still works.
|
|
173
|
+
"""
|
|
174
|
+
credential = f"{self.username}_{self.password}".encode()
|
|
175
|
+
return [
|
|
176
|
+
hashlib.sha256(credential).hexdigest(),
|
|
177
|
+
hashlib.md5(credential).hexdigest(),
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# --- Auth ---------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def login(self) -> ResolveError | None:
|
|
183
|
+
"""Authenticate and cache the session key. Returns None on success.
|
|
184
|
+
|
|
185
|
+
Tries each configured host in order. A host that can't be reached
|
|
186
|
+
(transport failure) advances to the next; a host that answers with an
|
|
187
|
+
authentication error stops the walk immediately, because the same
|
|
188
|
+
credentials would fail on every host of the same array.
|
|
189
|
+
"""
|
|
190
|
+
last_transport_error = "unreachable"
|
|
191
|
+
for index in range(len(self.hosts)):
|
|
192
|
+
self._activate_host(index)
|
|
193
|
+
outcome = self._login_current_host()
|
|
194
|
+
if outcome is None:
|
|
195
|
+
return None # success — active host + session key are set
|
|
196
|
+
kind, message = outcome
|
|
197
|
+
if kind == "transport":
|
|
198
|
+
last_transport_error = message
|
|
199
|
+
continue # this host is down; try the next
|
|
200
|
+
# Definitive auth/response error — same creds fail on every host.
|
|
201
|
+
return ResolveError(
|
|
202
|
+
f"Login to MSA array '{self.name}' failed (tried SHA-256 and MD5): {message}"
|
|
203
|
+
)
|
|
204
|
+
hosts = ", ".join(self.hosts)
|
|
205
|
+
return ResolveError(
|
|
206
|
+
f"Could not reach MSA array '{self.name}' on any host ({hosts}): {last_transport_error}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _login_current_host(self) -> tuple[str, str] | None:
|
|
210
|
+
"""Attempt login against the active host.
|
|
211
|
+
|
|
212
|
+
Returns None on success; ``("transport", msg)`` when the host is
|
|
213
|
+
unreachable; ``("auth", msg)`` when the host answered but rejected the
|
|
214
|
+
credentials (or returned an unparseable/again-erroring response).
|
|
215
|
+
"""
|
|
216
|
+
http = self._http_client()
|
|
217
|
+
last_auth_error = "authentication failed"
|
|
218
|
+
for digest in self._login_hashes():
|
|
219
|
+
try:
|
|
220
|
+
resp = http.get(f"/login/{digest}")
|
|
221
|
+
except httpx.HTTPError as exc:
|
|
222
|
+
return ("transport", redact_exception(exc))
|
|
223
|
+
payload = self._parse_json(resp)
|
|
224
|
+
if isinstance(payload, ResolveError):
|
|
225
|
+
last_auth_error = payload.message
|
|
226
|
+
continue
|
|
227
|
+
status = _extract_status(payload)
|
|
228
|
+
if not _status_is_error(status):
|
|
229
|
+
key = status.get("response")
|
|
230
|
+
if isinstance(key, str) and key:
|
|
231
|
+
self._session_key = key
|
|
232
|
+
return None
|
|
233
|
+
last_auth_error = redact_secrets(str(status.get("response", "authentication failed")))
|
|
234
|
+
return ("auth", last_auth_error)
|
|
235
|
+
|
|
236
|
+
# --- Requests -----------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def request(self, segments: list[str]) -> dict[str, Any] | ResolveError:
|
|
239
|
+
"""Run a ``GET /api/<segments...>`` call, logging in as needed.
|
|
240
|
+
|
|
241
|
+
``segments`` are the already-validated path components after ``/api``
|
|
242
|
+
(e.g. ``["show", "disks"]``). On an expired session the client
|
|
243
|
+
re-logs-in once and retries; any second failure is returned as a
|
|
244
|
+
:class:`ResolveError`.
|
|
245
|
+
|
|
246
|
+
Returns the parsed response dict (envelope intact, including the
|
|
247
|
+
trailing ``status`` array) so the caller can inspect status before
|
|
248
|
+
trusting the payload.
|
|
249
|
+
"""
|
|
250
|
+
if self._session_key is None:
|
|
251
|
+
err = self.login()
|
|
252
|
+
if err is not None:
|
|
253
|
+
return err
|
|
254
|
+
|
|
255
|
+
result = self._get(segments)
|
|
256
|
+
if isinstance(result, ResolveError):
|
|
257
|
+
return result
|
|
258
|
+
payload, http_status = result
|
|
259
|
+
|
|
260
|
+
status = _extract_status(payload)
|
|
261
|
+
if http_status == 401 or _status_is_expired(status):
|
|
262
|
+
# Session went stale — re-login once and retry.
|
|
263
|
+
self._session_key = None
|
|
264
|
+
err = self.login()
|
|
265
|
+
if err is not None:
|
|
266
|
+
return err
|
|
267
|
+
retry = self._get(segments)
|
|
268
|
+
if isinstance(retry, ResolveError):
|
|
269
|
+
return retry
|
|
270
|
+
payload, _ = retry
|
|
271
|
+
|
|
272
|
+
return payload
|
|
273
|
+
|
|
274
|
+
def _get(self, segments: list[str]) -> tuple[dict[str, Any], int] | ResolveError:
|
|
275
|
+
"""Single GET with the session-key header. Returns (payload, http_status)."""
|
|
276
|
+
http = self._http_client()
|
|
277
|
+
path = "/" + "/".join(segments)
|
|
278
|
+
headers = {"sessionKey": self._session_key} if self._session_key else {}
|
|
279
|
+
try:
|
|
280
|
+
resp = http.get(path, headers=headers)
|
|
281
|
+
except httpx.HTTPError as exc:
|
|
282
|
+
return ResolveError(
|
|
283
|
+
f"Request to MSA array '{self.name}' failed: {redact_exception(exc)}"
|
|
284
|
+
)
|
|
285
|
+
payload = self._parse_json(resp)
|
|
286
|
+
if isinstance(payload, ResolveError):
|
|
287
|
+
return payload
|
|
288
|
+
return payload, resp.status_code
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _parse_json(resp: httpx.Response) -> dict[str, Any] | ResolveError:
|
|
292
|
+
"""Parse a response body as JSON, or return a redacted error."""
|
|
293
|
+
try:
|
|
294
|
+
data = resp.json()
|
|
295
|
+
except ValueError:
|
|
296
|
+
snippet = redact_secrets(resp.text[:200])
|
|
297
|
+
return ResolveError(
|
|
298
|
+
f"MSA returned a non-JSON response (HTTP {resp.status_code}): {snippet}"
|
|
299
|
+
)
|
|
300
|
+
if not isinstance(data, dict):
|
|
301
|
+
return ResolveError("MSA returned an unexpected (non-object) JSON response.")
|
|
302
|
+
return data
|
|
303
|
+
|
|
304
|
+
def close(self) -> None:
|
|
305
|
+
"""Close the underlying HTTP client. Best-effort; used on shutdown/tests."""
|
|
306
|
+
if self._http is not None and not self._http.is_closed:
|
|
307
|
+
# Logout is best-effort — MSA idles the session out anyway.
|
|
308
|
+
with contextlib.suppress(httpx.HTTPError):
|
|
309
|
+
self._http.get("/exit", headers={"sessionKey": self._session_key or ""})
|
|
310
|
+
self._http.close()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --- Client cache -----------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
_CLIENTS: dict[str, MSAClient] = {}
|
|
316
|
+
"""Cache of constructed clients keyed by array name. Lifetime = server process."""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_client(name: str | None, *, tls: TlsConfig) -> MSAClient | ResolveError:
|
|
320
|
+
"""Return a cached, logged-in client for the named array.
|
|
321
|
+
|
|
322
|
+
Resolves the array from the registry (or the ``MSA_HOST`` env fallback),
|
|
323
|
+
constructs a client if one isn't cached, and logs it in. Returns
|
|
324
|
+
:class:`ResolveError` instead of raising so tools can surface a clean
|
|
325
|
+
message without try/except.
|
|
326
|
+
"""
|
|
327
|
+
entry = resolve_array(name)
|
|
328
|
+
if entry is None:
|
|
329
|
+
if name is None:
|
|
330
|
+
from .config.arrays import is_multi_array
|
|
331
|
+
|
|
332
|
+
if is_multi_array():
|
|
333
|
+
return ResolveError(
|
|
334
|
+
"Multiple arrays are configured — specify which one with the "
|
|
335
|
+
"'array' parameter (use list_arrays to see configured targets)."
|
|
336
|
+
)
|
|
337
|
+
return ResolveError(
|
|
338
|
+
"No MSA array configured. Set MSA_HOST/MSA_USERNAME/MSA_PASSWORD or "
|
|
339
|
+
"create an arrays.json with at least one entry."
|
|
340
|
+
)
|
|
341
|
+
return ResolveError(
|
|
342
|
+
f"MSA array '{name}' not found. Use list_arrays to see configured targets."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
cached = _CLIENTS.get(entry.name)
|
|
346
|
+
if cached is not None:
|
|
347
|
+
return cached
|
|
348
|
+
|
|
349
|
+
client = _build_client(entry, tls)
|
|
350
|
+
err = client.login()
|
|
351
|
+
if err is not None:
|
|
352
|
+
return err
|
|
353
|
+
_CLIENTS[entry.name] = client
|
|
354
|
+
return client
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _build_client(entry: ArrayEntry, tls: TlsConfig) -> MSAClient:
|
|
358
|
+
"""Construct (without logging in) an MSAClient for a resolved entry."""
|
|
359
|
+
assert entry.password is not None # resolve_array only returns entries with a password
|
|
360
|
+
return MSAClient(
|
|
361
|
+
name=entry.name,
|
|
362
|
+
hosts=list(entry.hosts),
|
|
363
|
+
username=entry.username,
|
|
364
|
+
password=entry.password,
|
|
365
|
+
tls=tls,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def reset_client_cache() -> None:
|
|
370
|
+
"""Drop all cached clients. Used by tests; rarely needed at runtime."""
|
|
371
|
+
for client in _CLIENTS.values():
|
|
372
|
+
client.close()
|
|
373
|
+
_CLIENTS.clear()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def probe_client(name: str | None, *, tls: TlsConfig) -> dict[str, Any]:
|
|
377
|
+
"""Light-touch metadata for ``server_status``: confirms a target resolves.
|
|
378
|
+
|
|
379
|
+
Does NOT make a network call — that would slow startup and add a network
|
|
380
|
+
dependency to a status tool. Just confirms the config-side resolution
|
|
381
|
+
chain works.
|
|
382
|
+
"""
|
|
383
|
+
entry = resolve_array(name)
|
|
384
|
+
if entry is None:
|
|
385
|
+
return {"resolved": False}
|
|
386
|
+
return {"resolved": True, "name": entry.name, "host": entry.host}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
__all__ = [
|
|
390
|
+
"MSAClient",
|
|
391
|
+
"ResolveError",
|
|
392
|
+
"get_client",
|
|
393
|
+
"probe_client",
|
|
394
|
+
"reset_client_cache",
|
|
395
|
+
]
|
|
File without changes
|