redfish-python-sdk 1.0.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.
- redfish_python_sdk-1.0.0.dist-info/METADATA +164 -0
- redfish_python_sdk-1.0.0.dist-info/RECORD +56 -0
- redfish_python_sdk-1.0.0.dist-info/WHEEL +5 -0
- redfish_python_sdk-1.0.0.dist-info/licenses/LICENSE +29 -0
- redfish_python_sdk-1.0.0.dist-info/top_level.txt +1 -0
- redfish_sdk/__init__.py +63 -0
- redfish_sdk/client.py +1894 -0
- redfish_sdk/exceptions.py +56 -0
- redfish_sdk/http_client.py +452 -0
- redfish_sdk/managers/__init__.py +21 -0
- redfish_sdk/managers/_log_helpers.py +144 -0
- redfish_sdk/managers/account.py +96 -0
- redfish_sdk/managers/chassis.py +327 -0
- redfish_sdk/managers/event.py +264 -0
- redfish_sdk/managers/managers.py +120 -0
- redfish_sdk/managers/registries.py +48 -0
- redfish_sdk/managers/session.py +130 -0
- redfish_sdk/managers/systems.py +630 -0
- redfish_sdk/managers/task.py +89 -0
- redfish_sdk/managers/update.py +99 -0
- redfish_sdk/managers/update_strategies/__init__.py +51 -0
- redfish_sdk/managers/update_strategies/base.py +101 -0
- redfish_sdk/managers/update_strategies/h3c.py +140 -0
- redfish_sdk/managers/update_strategies/inspur.py +89 -0
- redfish_sdk/managers/update_strategies/lenovo.py +59 -0
- redfish_sdk/managers/update_strategies/nettrix.py +56 -0
- redfish_sdk/managers/update_strategies/registry.py +72 -0
- redfish_sdk/managers/update_strategies/vendor_detect.py +111 -0
- redfish_sdk/managers/update_strategies/xfusion.py +60 -0
- redfish_sdk/managers/update_strategies/zte.py +68 -0
- redfish_sdk/models/__init__.py +55 -0
- redfish_sdk/models/account.py +60 -0
- redfish_sdk/models/chassis.py +86 -0
- redfish_sdk/models/check.py +231 -0
- redfish_sdk/models/common.py +102 -0
- redfish_sdk/models/drive.py +53 -0
- redfish_sdk/models/event.py +64 -0
- redfish_sdk/models/fru.py +59 -0
- redfish_sdk/models/gpu.py +33 -0
- redfish_sdk/models/logs.py +56 -0
- redfish_sdk/models/managers.py +153 -0
- redfish_sdk/models/memory.py +50 -0
- redfish_sdk/models/network_adapter.py +76 -0
- redfish_sdk/models/oem.py +173 -0
- redfish_sdk/models/pcie_device.py +89 -0
- redfish_sdk/models/power.py +92 -0
- redfish_sdk/models/processor.py +50 -0
- redfish_sdk/models/registry.py +34 -0
- redfish_sdk/models/resource_key.py +70 -0
- redfish_sdk/models/root.py +50 -0
- redfish_sdk/models/session.py +42 -0
- redfish_sdk/models/storage.py +77 -0
- redfish_sdk/models/systems.py +194 -0
- redfish_sdk/models/task.py +54 -0
- redfish_sdk/models/thermal.py +111 -0
- redfish_sdk/models/update.py +55 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the Redfish Python SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RedfishException(Exception):
|
|
7
|
+
"""
|
|
8
|
+
Base exception for all Redfish SDK errors.
|
|
9
|
+
|
|
10
|
+
Raised when a Redfish API call returns a non-successful HTTP status code.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status_code: int, message: str, body: str = ""):
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.message = message
|
|
16
|
+
self.body = body
|
|
17
|
+
super().__init__(f"[HTTP {status_code}] {message}")
|
|
18
|
+
|
|
19
|
+
def __repr__(self) -> str:
|
|
20
|
+
return f"RedfishException(status_code={self.status_code}, message={self.message!r})"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RedfishNotFoundError(RedfishException):
|
|
24
|
+
"""Raised when a resource is not found (HTTP 404)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, path: str):
|
|
27
|
+
super().__init__(404, f"Resource not found: {path}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RedfishAuthError(RedfishException):
|
|
31
|
+
"""Raised when authentication fails (HTTP 401 / 403)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, status_code: int = 401):
|
|
34
|
+
super().__init__(status_code, "Authentication failed. Check username and password.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RedfishConnectionError(RedfishException):
|
|
38
|
+
"""Raised when unable to connect to the BMC."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, host: str, cause: Exception = None):
|
|
41
|
+
super().__init__(0, f"Unable to connect to host: {host}. Cause: {cause}")
|
|
42
|
+
self.cause = cause
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RedfishTimeoutError(RedfishException):
|
|
46
|
+
"""Raised when a request times out."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, host: str):
|
|
49
|
+
super().__init__(0, f"Request timed out connecting to: {host}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RedfishValidationError(RedfishException):
|
|
53
|
+
"""Raised for invalid input parameters (e.g., unsupported reset type)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str):
|
|
56
|
+
super().__init__(400, message)
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redfish HTTP client — the core transport layer.
|
|
3
|
+
|
|
4
|
+
Provides low-level GET/POST/PATCH/DELETE operations against a Redfish BMC endpoint.
|
|
5
|
+
|
|
6
|
+
Design notes:
|
|
7
|
+
- Uses Basic Auth (base64-encoded "username:password") in Authorization header
|
|
8
|
+
- Skips SSL certificate verification (Redfish BMCs use self-signed certs)
|
|
9
|
+
- Supports HTTP proxy
|
|
10
|
+
- Extracts ETag from GET responses and sends If-Match on PATCH requests
|
|
11
|
+
- Raises RedfishException for non-2xx responses
|
|
12
|
+
- Connection timeout: 10s, Read timeout: 30s (configurable)
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Dict, Optional, Type, TypeVar
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
import urllib3
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
RedfishAuthError,
|
|
27
|
+
RedfishConnectionError,
|
|
28
|
+
RedfishException,
|
|
29
|
+
RedfishNotFoundError,
|
|
30
|
+
RedfishTimeoutError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Suppress InsecureRequestWarning for self-signed certs
|
|
34
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T", bound=BaseModel)
|
|
39
|
+
|
|
40
|
+
# HTTP status codes that indicate success
|
|
41
|
+
_SUCCESS_CODES = {200, 201, 202, 204, 302}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RedfishHttpClient:
|
|
45
|
+
"""
|
|
46
|
+
Low-level HTTP client for Redfish API calls.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
import os
|
|
50
|
+
client = RedfishHttpClient(
|
|
51
|
+
host=os.environ["BMC_IP"],
|
|
52
|
+
username=os.environ["BMC_USERNAME"],
|
|
53
|
+
password=os.environ["BMC_PASSWORD"],
|
|
54
|
+
verify_ssl=False,
|
|
55
|
+
)
|
|
56
|
+
root = client.get("/redfish/v1/", RootService)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
host: str,
|
|
62
|
+
username: str,
|
|
63
|
+
password: str,
|
|
64
|
+
verify_ssl: bool = False,
|
|
65
|
+
proxy: Optional[str] = None,
|
|
66
|
+
connect_timeout: int = 10,
|
|
67
|
+
read_timeout: int = 30,
|
|
68
|
+
scheme: str = "https",
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
Initialize the Redfish HTTP client.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
host: BMC IP address or hostname (e.g., "192.0.2.10")
|
|
75
|
+
username: BMC username
|
|
76
|
+
password: BMC password
|
|
77
|
+
verify_ssl: Whether to verify SSL certificates. Default False (BMCs use self-signed).
|
|
78
|
+
proxy: Optional HTTP/HTTPS proxy URL (e.g., "http://127.0.0.1:8080")
|
|
79
|
+
connect_timeout: Connection timeout in seconds
|
|
80
|
+
read_timeout: Read timeout in seconds
|
|
81
|
+
scheme: URL scheme, "https" (default) or "http"
|
|
82
|
+
"""
|
|
83
|
+
self.host = host
|
|
84
|
+
self.scheme = scheme
|
|
85
|
+
self.verify_ssl = verify_ssl
|
|
86
|
+
self.connect_timeout = connect_timeout
|
|
87
|
+
self.read_timeout = read_timeout
|
|
88
|
+
|
|
89
|
+
# Pre-compute Basic Auth header (same as Java's base64 encoding)
|
|
90
|
+
credentials = f"{username}:{password}"
|
|
91
|
+
self._basic_auth = "Basic " + base64.b64encode(credentials.encode()).decode()
|
|
92
|
+
|
|
93
|
+
# Session with proxy and default headers
|
|
94
|
+
self._session = requests.Session()
|
|
95
|
+
self._session.verify = verify_ssl
|
|
96
|
+
self._session.headers.update({
|
|
97
|
+
"Authorization": self._basic_auth,
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"Accept": "application/json",
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if proxy:
|
|
103
|
+
self._session.proxies = {"http": proxy, "https": proxy}
|
|
104
|
+
|
|
105
|
+
# Track the last ETag received for use in subsequent PATCH requests
|
|
106
|
+
self._last_etag: Dict[str, str] = {}
|
|
107
|
+
|
|
108
|
+
def _build_url(self, path: str) -> str:
|
|
109
|
+
"""Build a full URL from a Redfish path."""
|
|
110
|
+
path = path if path.startswith("/") else f"/{path}"
|
|
111
|
+
return f"{self.scheme}://{self.host}{path}"
|
|
112
|
+
|
|
113
|
+
def _get_etag(self, path: str, data: Optional[BaseModel]) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Get the ETag for a resource.
|
|
116
|
+
If the model has an odata_etag attribute, use that; otherwise use '*' (wildcard).
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
"""
|
|
120
|
+
if data is not None and hasattr(data, "odata_etag") and data.odata_etag:
|
|
121
|
+
return data.odata_etag
|
|
122
|
+
return self._last_etag.get(path, "*")
|
|
123
|
+
|
|
124
|
+
def _store_etag(self, path: str, response: requests.Response) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Extract and store ETag from response headers.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
etag = response.headers.get("ETag") or response.headers.get("etag")
|
|
131
|
+
if etag:
|
|
132
|
+
self._last_etag[path] = etag
|
|
133
|
+
|
|
134
|
+
def _raise_for_status(self, response: requests.Response, path: str) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Raise a typed RedfishException for non-2xx responses.
|
|
137
|
+
"""
|
|
138
|
+
code = response.status_code
|
|
139
|
+
if code in _SUCCESS_CODES:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
body = ""
|
|
143
|
+
try:
|
|
144
|
+
body = response.text
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
logger.error("Request failed: %s %s -> HTTP %d, body: %s",
|
|
149
|
+
response.request.method, path, code, body[:500])
|
|
150
|
+
|
|
151
|
+
if code in (401, 403):
|
|
152
|
+
raise RedfishAuthError(code)
|
|
153
|
+
if code == 404:
|
|
154
|
+
raise RedfishNotFoundError(path)
|
|
155
|
+
raise RedfishException(code, f"Request to {path} failed", body)
|
|
156
|
+
|
|
157
|
+
def _parse(self, response: requests.Response, model_class: Type[T]) -> T:
|
|
158
|
+
"""Parse JSON response into a pydantic model."""
|
|
159
|
+
try:
|
|
160
|
+
data = response.json()
|
|
161
|
+
return model_class.model_validate(data)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
logger.error("Failed to parse response as %s: %s", model_class.__name__, exc)
|
|
164
|
+
raise RedfishException(
|
|
165
|
+
response.status_code,
|
|
166
|
+
f"Failed to parse response as {model_class.__name__}: {exc}",
|
|
167
|
+
response.text[:1000],
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Public HTTP methods
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def get(self, path: str, model_class: Type[T]) -> T:
|
|
175
|
+
"""
|
|
176
|
+
Send a GET request and return the parsed response.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
path: Redfish resource path (e.g., "/redfish/v1/Systems/1")
|
|
180
|
+
model_class: Pydantic model class to deserialize the response into
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Parsed model instance
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
RedfishException: On non-2xx HTTP responses
|
|
187
|
+
RedfishConnectionError: On network errors
|
|
188
|
+
RedfishTimeoutError: On timeout
|
|
189
|
+
"""
|
|
190
|
+
url = self._build_url(path)
|
|
191
|
+
logger.debug("GET %s", url)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
response = self._session.get(
|
|
195
|
+
url, timeout=(self.connect_timeout, self.read_timeout)
|
|
196
|
+
)
|
|
197
|
+
except requests.exceptions.Timeout as exc:
|
|
198
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
199
|
+
except requests.exceptions.ConnectionError as exc:
|
|
200
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
201
|
+
|
|
202
|
+
logger.debug("GET %s -> HTTP %d", url, response.status_code)
|
|
203
|
+
self._store_etag(path, response)
|
|
204
|
+
self._raise_for_status(response, path)
|
|
205
|
+
result = self._parse(response, model_class)
|
|
206
|
+
|
|
207
|
+
# Store etag on the model itself if it's an Entity
|
|
208
|
+
if hasattr(result, "odata_etag") and not result.odata_etag:
|
|
209
|
+
etag = self._last_etag.get(path)
|
|
210
|
+
if etag:
|
|
211
|
+
result.odata_etag = etag
|
|
212
|
+
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
def get_raw(self, path: str) -> Any:
|
|
216
|
+
"""
|
|
217
|
+
Send a GET request and return the raw JSON dict (for dynamic structures).
|
|
218
|
+
"""
|
|
219
|
+
url = self._build_url(path)
|
|
220
|
+
logger.debug("GET (raw) %s", url)
|
|
221
|
+
try:
|
|
222
|
+
response = self._session.get(
|
|
223
|
+
url, timeout=(self.connect_timeout, self.read_timeout)
|
|
224
|
+
)
|
|
225
|
+
except requests.exceptions.Timeout as exc:
|
|
226
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
227
|
+
except requests.exceptions.ConnectionError as exc:
|
|
228
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
229
|
+
|
|
230
|
+
self._store_etag(path, response)
|
|
231
|
+
self._raise_for_status(response, path)
|
|
232
|
+
return response.json()
|
|
233
|
+
|
|
234
|
+
def post(self, path: str, model_class: Type[T], body: Optional[BaseModel] = None,
|
|
235
|
+
raw_body: Optional[Dict] = None) -> T:
|
|
236
|
+
"""
|
|
237
|
+
Send a POST request and return the parsed response.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
path: Redfish resource path
|
|
241
|
+
model_class: Pydantic model to deserialize response into
|
|
242
|
+
body: Optional pydantic model to serialize as request body
|
|
243
|
+
raw_body: Optional raw dict as request body (alternative to body)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Parsed model instance
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
RedfishException: On non-2xx HTTP responses
|
|
250
|
+
"""
|
|
251
|
+
url = self._build_url(path)
|
|
252
|
+
json_payload = None
|
|
253
|
+
if body is not None:
|
|
254
|
+
json_payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
255
|
+
elif raw_body is not None:
|
|
256
|
+
json_payload = raw_body
|
|
257
|
+
|
|
258
|
+
logger.info("POST %s, payload: %s", url, json_payload)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
response = self._session.post(
|
|
262
|
+
url,
|
|
263
|
+
json=json_payload,
|
|
264
|
+
timeout=(self.connect_timeout, self.read_timeout),
|
|
265
|
+
)
|
|
266
|
+
except requests.exceptions.Timeout as exc:
|
|
267
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
268
|
+
except requests.exceptions.ConnectionError as exc:
|
|
269
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
270
|
+
|
|
271
|
+
logger.info("POST %s -> HTTP %d", url, response.status_code)
|
|
272
|
+
self._store_etag(path, response)
|
|
273
|
+
self._raise_for_status(response, path)
|
|
274
|
+
|
|
275
|
+
# 204 No Content — return empty model
|
|
276
|
+
if response.status_code == 204 or not response.text.strip():
|
|
277
|
+
return model_class.model_construct()
|
|
278
|
+
|
|
279
|
+
return self._parse(response, model_class)
|
|
280
|
+
|
|
281
|
+
def post_raw(self, path: str, body: Optional[Dict] = None) -> requests.Response:
|
|
282
|
+
"""
|
|
283
|
+
Send a POST request and return the raw Response object.
|
|
284
|
+
Useful when the caller needs response headers (e.g., X-Auth-Token).
|
|
285
|
+
"""
|
|
286
|
+
url = self._build_url(path)
|
|
287
|
+
logger.info("POST (raw) %s, payload: %s", url, body)
|
|
288
|
+
try:
|
|
289
|
+
response = self._session.post(
|
|
290
|
+
url,
|
|
291
|
+
json=body,
|
|
292
|
+
timeout=(self.connect_timeout, self.read_timeout),
|
|
293
|
+
)
|
|
294
|
+
except requests.exceptions.Timeout as exc:
|
|
295
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
296
|
+
except requests.exceptions.ConnectionError as exc:
|
|
297
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
298
|
+
|
|
299
|
+
logger.info("POST (raw) %s -> HTTP %d", url, response.status_code)
|
|
300
|
+
self._store_etag(path, response)
|
|
301
|
+
self._raise_for_status(response, path)
|
|
302
|
+
return response
|
|
303
|
+
|
|
304
|
+
def patch(self, path: str, model_class: Type[T], body: BaseModel,
|
|
305
|
+
extra_headers: Optional[Dict[str, str]] = None) -> T:
|
|
306
|
+
"""
|
|
307
|
+
Send a PATCH request and return the parsed response.
|
|
308
|
+
|
|
309
|
+
Automatically sets the If-Match header using the ETag from the entity
|
|
310
|
+
(or '*' if no ETag is available).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
path: Redfish resource path
|
|
314
|
+
model_class: Pydantic model to deserialize response into
|
|
315
|
+
body: Pydantic model to serialize as request body (must be Entity for ETag)
|
|
316
|
+
extra_headers: Additional headers to include (e.g., Content-Type overrides)
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Parsed model instance
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
RedfishException: On non-2xx HTTP responses
|
|
323
|
+
"""
|
|
324
|
+
url = self._build_url(path)
|
|
325
|
+
etag = self._get_etag(path, body)
|
|
326
|
+
json_payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
327
|
+
|
|
328
|
+
headers = {"If-Match": etag}
|
|
329
|
+
if extra_headers:
|
|
330
|
+
headers.update(extra_headers)
|
|
331
|
+
|
|
332
|
+
logger.info("PATCH %s, If-Match: %s, payload: %s", url, etag, json_payload)
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
response = self._session.patch(
|
|
336
|
+
url,
|
|
337
|
+
json=json_payload,
|
|
338
|
+
headers=headers,
|
|
339
|
+
timeout=(self.connect_timeout, self.read_timeout),
|
|
340
|
+
)
|
|
341
|
+
except requests.exceptions.Timeout as exc:
|
|
342
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
343
|
+
except requests.exceptions.ConnectionError as exc:
|
|
344
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
345
|
+
|
|
346
|
+
logger.info("PATCH %s -> HTTP %d", url, response.status_code)
|
|
347
|
+
self._store_etag(path, response)
|
|
348
|
+
self._raise_for_status(response, path)
|
|
349
|
+
|
|
350
|
+
# 204 No Content
|
|
351
|
+
if response.status_code == 204 or not response.text.strip():
|
|
352
|
+
return model_class.model_construct()
|
|
353
|
+
|
|
354
|
+
return self._parse(response, model_class)
|
|
355
|
+
|
|
356
|
+
def patch_raw(self, path: str, body: Dict, extra_headers: Optional[Dict[str, str]] = None) -> requests.Response:
|
|
357
|
+
"""
|
|
358
|
+
Send a PATCH request with a raw dict body and return the raw Response object.
|
|
359
|
+
|
|
360
|
+
Automatically sets the If-Match header using the cached ETag
|
|
361
|
+
(or '*' if no ETag is available).
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
path: Redfish resource path
|
|
365
|
+
body: Raw dict to serialize as JSON request body
|
|
366
|
+
extra_headers: Additional headers to include
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Raw Response object
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
RedfishException: On non-2xx HTTP responses
|
|
373
|
+
"""
|
|
374
|
+
url = self._build_url(path)
|
|
375
|
+
etag = self._last_etag.get(path, "*")
|
|
376
|
+
|
|
377
|
+
headers = {"If-Match": etag}
|
|
378
|
+
if extra_headers:
|
|
379
|
+
headers.update(extra_headers)
|
|
380
|
+
|
|
381
|
+
logger.info("PATCH (raw) %s, If-Match: %s, payload: %s", url, etag, body)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
response = self._session.patch(
|
|
385
|
+
url,
|
|
386
|
+
json=body,
|
|
387
|
+
headers=headers,
|
|
388
|
+
timeout=(self.connect_timeout, self.read_timeout),
|
|
389
|
+
)
|
|
390
|
+
except requests.exceptions.Timeout as exc:
|
|
391
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
392
|
+
except requests.exceptions.ConnectionError as exc:
|
|
393
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
394
|
+
|
|
395
|
+
logger.info("PATCH (raw) %s -> HTTP %d", url, response.status_code)
|
|
396
|
+
self._store_etag(path, response)
|
|
397
|
+
self._raise_for_status(response, path)
|
|
398
|
+
return response
|
|
399
|
+
|
|
400
|
+
def delete(self, path: str) -> str:
|
|
401
|
+
"""
|
|
402
|
+
Send a DELETE request.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
path: Redfish resource path
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Response body as string (usually empty for 204)
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
RedfishException: On non-2xx HTTP responses
|
|
412
|
+
"""
|
|
413
|
+
url = self._build_url(path)
|
|
414
|
+
logger.info("DELETE %s", url)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
response = self._session.delete(
|
|
418
|
+
url, timeout=(self.connect_timeout, self.read_timeout)
|
|
419
|
+
)
|
|
420
|
+
except requests.exceptions.Timeout as exc:
|
|
421
|
+
raise RedfishTimeoutError(self.host) from exc
|
|
422
|
+
except requests.exceptions.ConnectionError as exc:
|
|
423
|
+
raise RedfishConnectionError(self.host, exc) from exc
|
|
424
|
+
|
|
425
|
+
logger.info("DELETE %s -> HTTP %d", url, response.status_code)
|
|
426
|
+
self._raise_for_status(response, path)
|
|
427
|
+
return response.text
|
|
428
|
+
|
|
429
|
+
def set_auth_token(self, token: str) -> None:
|
|
430
|
+
"""
|
|
431
|
+
Switch from Basic Auth to Session-based auth (X-Auth-Token).
|
|
432
|
+
Called after successfully creating a session.
|
|
433
|
+
"""
|
|
434
|
+
self._session.headers.pop("Authorization", None)
|
|
435
|
+
self._session.headers["X-Auth-Token"] = token
|
|
436
|
+
logger.debug("Switched to X-Auth-Token authentication")
|
|
437
|
+
|
|
438
|
+
def reset_basic_auth(self) -> None:
|
|
439
|
+
"""Switch back to Basic Auth (e.g., after session deletion)."""
|
|
440
|
+
self._session.headers.pop("X-Auth-Token", None)
|
|
441
|
+
self._session.headers["Authorization"] = self._basic_auth
|
|
442
|
+
logger.debug("Switched back to Basic Auth")
|
|
443
|
+
|
|
444
|
+
def close(self) -> None:
|
|
445
|
+
"""Close the underlying HTTP session."""
|
|
446
|
+
self._session.close()
|
|
447
|
+
|
|
448
|
+
def __enter__(self) -> RedfishHttpClient:
|
|
449
|
+
return self
|
|
450
|
+
|
|
451
|
+
def __exit__(self, *args) -> None:
|
|
452
|
+
self.close()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .account import AccountServiceManager
|
|
2
|
+
from .chassis import ChassisManager
|
|
3
|
+
from .event import EventServiceManager
|
|
4
|
+
from .managers import ManagersManager
|
|
5
|
+
from .registries import RegistriesManager
|
|
6
|
+
from .session import SessionServiceManager
|
|
7
|
+
from .systems import SystemsManager
|
|
8
|
+
from .task import TaskServiceManager
|
|
9
|
+
from .update import UpdateServiceManager
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SystemsManager",
|
|
13
|
+
"ChassisManager",
|
|
14
|
+
"ManagersManager",
|
|
15
|
+
"AccountServiceManager",
|
|
16
|
+
"SessionServiceManager",
|
|
17
|
+
"EventServiceManager",
|
|
18
|
+
"UpdateServiceManager",
|
|
19
|
+
"RegistriesManager",
|
|
20
|
+
"TaskServiceManager",
|
|
21
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared helpers for fetching log services and their entries.
|
|
3
|
+
|
|
4
|
+
Used by both :mod:`redfish_sdk.managers.systems` and
|
|
5
|
+
:mod:`redfish_sdk.managers.managers` so the two log services share the
|
|
6
|
+
same behaviour: missing-LogServices guard, dynamic LogService URL
|
|
7
|
+
discovery (no path concatenation), and log_id auto-selection.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
13
|
+
|
|
14
|
+
from ..exceptions import (
|
|
15
|
+
RedfishException,
|
|
16
|
+
RedfishNotFoundError,
|
|
17
|
+
RedfishValidationError,
|
|
18
|
+
)
|
|
19
|
+
from ..models.logs import Log, LogEntry
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ..client import RedfishClient
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require_log_services_link(
|
|
28
|
+
parent_resource: object,
|
|
29
|
+
parent_name: str,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Extract ``LogServices.@odata.id`` from a System/Manager resource, raising
|
|
33
|
+
a clear error when the BMC did not advertise a LogServices link at all.
|
|
34
|
+
|
|
35
|
+
The ``Optional[Link]`` typing on ``System.log_services`` / ``Manager.log_services``
|
|
36
|
+
reflects the Redfish spec: ``LogServices`` is an optional sub-resource and
|
|
37
|
+
some lightweight BMCs (or disabled audit roles) genuinely omit it. Without
|
|
38
|
+
this guard, callers crash with a bare ``AttributeError: 'NoneType' object``
|
|
39
|
+
that doesn't reveal which BMC capability is missing.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
parent_resource: A System or Manager instance with a ``log_services`` attr.
|
|
43
|
+
parent_name: Human label for the error message (e.g. ``"System 1"``).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The ``@odata.id`` of the LogServices collection.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
RedfishException: 404 when the parent resource does not expose
|
|
50
|
+
LogServices.
|
|
51
|
+
"""
|
|
52
|
+
link = getattr(parent_resource, "log_services", None)
|
|
53
|
+
odata_id = getattr(link, "odata_id", None) if link is not None else None
|
|
54
|
+
if not odata_id:
|
|
55
|
+
raise RedfishException(
|
|
56
|
+
404,
|
|
57
|
+
f"{parent_name} does not expose a LogServices collection "
|
|
58
|
+
f"(BMC returned no `LogServices` link on the resource)",
|
|
59
|
+
)
|
|
60
|
+
return odata_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_log_service(
|
|
64
|
+
client: "RedfishClient",
|
|
65
|
+
log_services_odata_id: str,
|
|
66
|
+
log_id: Optional[str],
|
|
67
|
+
) -> Log:
|
|
68
|
+
"""
|
|
69
|
+
Resolve a single :class:`Log` resource by ID or auto-select when only one.
|
|
70
|
+
|
|
71
|
+
Both branches discover the real per-LogService ``@odata.id`` by listing
|
|
72
|
+
the parent ``LogServices`` collection and matching by ``Log.id`` —
|
|
73
|
+
never assume the URL is ``f"{log_services_odata_id}/{log_id}"``. Some
|
|
74
|
+
vendors (e.g. Huawei iBMC OEM logs) publish a non-standard child path,
|
|
75
|
+
so trusting the collection link is the only correct approach.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
client: RedfishClient instance (used to fetch the LogServices collection).
|
|
79
|
+
log_services_odata_id: ``@odata.id`` of the parent ``LogServices`` collection.
|
|
80
|
+
log_id: Explicit log service ID, or None to auto-select the sole member.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
RedfishException: 404 when the collection is empty.
|
|
84
|
+
RedfishValidationError: When ``log_id`` is None and multiple members exist.
|
|
85
|
+
RedfishNotFoundError: When the requested ``log_id`` is not present.
|
|
86
|
+
"""
|
|
87
|
+
services = client._get_collection(log_services_odata_id, Log)
|
|
88
|
+
if not services:
|
|
89
|
+
raise RedfishException(
|
|
90
|
+
404, f"No log services found under {log_services_odata_id}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if log_id is None:
|
|
94
|
+
if len(services) > 1:
|
|
95
|
+
ids = [s.id for s in services if s.id]
|
|
96
|
+
raise RedfishValidationError(
|
|
97
|
+
f"Multiple log services found, please specify log_id. "
|
|
98
|
+
f"Available: {ids}"
|
|
99
|
+
)
|
|
100
|
+
return services[0]
|
|
101
|
+
|
|
102
|
+
# Explicit log_id — look it up by ``Log.id`` in the collection rather
|
|
103
|
+
# than rebuilding the URL by string concatenation.
|
|
104
|
+
matches = [s for s in services if s.id == log_id]
|
|
105
|
+
if not matches:
|
|
106
|
+
available = [s.id for s in services if s.id]
|
|
107
|
+
raise RedfishNotFoundError(
|
|
108
|
+
f"{log_services_odata_id}/{log_id} "
|
|
109
|
+
f"(no log service with id={log_id!r}; available={available})"
|
|
110
|
+
)
|
|
111
|
+
return matches[0]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fetch_log_entries(client: "RedfishClient", log: Log) -> List[LogEntry]:
|
|
115
|
+
"""
|
|
116
|
+
Fetch all LogEntry items under a Log resource.
|
|
117
|
+
|
|
118
|
+
Strategy: always GET the Entries collection link from
|
|
119
|
+
``log.entries.odata_id`` (NOT a hard-coded ``/Entries`` suffix) and
|
|
120
|
+
expand its members one by one via the shared ``_get_collection``
|
|
121
|
+
helper.
|
|
122
|
+
|
|
123
|
+
Returns an empty list when the LogService has no Entries link at all
|
|
124
|
+
(some BMCs expose log services without entries, e.g. a disabled audit
|
|
125
|
+
log).
|
|
126
|
+
|
|
127
|
+
History — why we don't use ``?$expand=.($levels=1)``:
|
|
128
|
+
An earlier prototype tried ``$expand`` first and fell back to per-entry
|
|
129
|
+
GET only on 4xx errors or bare-link responses. Real-world testing
|
|
130
|
+
against multiple BMCs showed that
|
|
131
|
+
some servers **silently swallow** the query and return an empty
|
|
132
|
+
``Members`` collection with ``Members@odata.count: 0`` even when
|
|
133
|
+
actual entries exist. There is no reliable wire-level signal that
|
|
134
|
+
distinguishes "BMC genuinely has no entries" from "BMC broke
|
|
135
|
+
expand"; both look like the same valid Redfish empty collection.
|
|
136
|
+
Per-entry GET via ``_get_collection`` is the only behaviour that
|
|
137
|
+
is correct across every BMC we have seen. Callers needing more
|
|
138
|
+
throughput can parallelise externally.
|
|
139
|
+
"""
|
|
140
|
+
if not log.entries or not log.entries.odata_id:
|
|
141
|
+
logger.debug("Log %s has no Entries link", log.odata_id)
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
return client._get_collection(log.entries.odata_id, LogEntry)
|