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.
Files changed (56) hide show
  1. redfish_python_sdk-1.0.0.dist-info/METADATA +164 -0
  2. redfish_python_sdk-1.0.0.dist-info/RECORD +56 -0
  3. redfish_python_sdk-1.0.0.dist-info/WHEEL +5 -0
  4. redfish_python_sdk-1.0.0.dist-info/licenses/LICENSE +29 -0
  5. redfish_python_sdk-1.0.0.dist-info/top_level.txt +1 -0
  6. redfish_sdk/__init__.py +63 -0
  7. redfish_sdk/client.py +1894 -0
  8. redfish_sdk/exceptions.py +56 -0
  9. redfish_sdk/http_client.py +452 -0
  10. redfish_sdk/managers/__init__.py +21 -0
  11. redfish_sdk/managers/_log_helpers.py +144 -0
  12. redfish_sdk/managers/account.py +96 -0
  13. redfish_sdk/managers/chassis.py +327 -0
  14. redfish_sdk/managers/event.py +264 -0
  15. redfish_sdk/managers/managers.py +120 -0
  16. redfish_sdk/managers/registries.py +48 -0
  17. redfish_sdk/managers/session.py +130 -0
  18. redfish_sdk/managers/systems.py +630 -0
  19. redfish_sdk/managers/task.py +89 -0
  20. redfish_sdk/managers/update.py +99 -0
  21. redfish_sdk/managers/update_strategies/__init__.py +51 -0
  22. redfish_sdk/managers/update_strategies/base.py +101 -0
  23. redfish_sdk/managers/update_strategies/h3c.py +140 -0
  24. redfish_sdk/managers/update_strategies/inspur.py +89 -0
  25. redfish_sdk/managers/update_strategies/lenovo.py +59 -0
  26. redfish_sdk/managers/update_strategies/nettrix.py +56 -0
  27. redfish_sdk/managers/update_strategies/registry.py +72 -0
  28. redfish_sdk/managers/update_strategies/vendor_detect.py +111 -0
  29. redfish_sdk/managers/update_strategies/xfusion.py +60 -0
  30. redfish_sdk/managers/update_strategies/zte.py +68 -0
  31. redfish_sdk/models/__init__.py +55 -0
  32. redfish_sdk/models/account.py +60 -0
  33. redfish_sdk/models/chassis.py +86 -0
  34. redfish_sdk/models/check.py +231 -0
  35. redfish_sdk/models/common.py +102 -0
  36. redfish_sdk/models/drive.py +53 -0
  37. redfish_sdk/models/event.py +64 -0
  38. redfish_sdk/models/fru.py +59 -0
  39. redfish_sdk/models/gpu.py +33 -0
  40. redfish_sdk/models/logs.py +56 -0
  41. redfish_sdk/models/managers.py +153 -0
  42. redfish_sdk/models/memory.py +50 -0
  43. redfish_sdk/models/network_adapter.py +76 -0
  44. redfish_sdk/models/oem.py +173 -0
  45. redfish_sdk/models/pcie_device.py +89 -0
  46. redfish_sdk/models/power.py +92 -0
  47. redfish_sdk/models/processor.py +50 -0
  48. redfish_sdk/models/registry.py +34 -0
  49. redfish_sdk/models/resource_key.py +70 -0
  50. redfish_sdk/models/root.py +50 -0
  51. redfish_sdk/models/session.py +42 -0
  52. redfish_sdk/models/storage.py +77 -0
  53. redfish_sdk/models/systems.py +194 -0
  54. redfish_sdk/models/task.py +54 -0
  55. redfish_sdk/models/thermal.py +111 -0
  56. 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)