-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathhuawei_dorado.py
More file actions
2489 lines (2148 loc) · 93.4 KB
/
Copy pathhuawei_dorado.py
File metadata and controls
2489 lines (2148 loc) · 93.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#! /usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.
# https://github.com/Linuxfabrik/lib/blob/main/CONTRIBUTING.md
"""This library collects functions for Huawei OceanStor Dorado storage systems,
which are accessed through the DeviceManager REST API (numeric status codes,
iBaseToken authentication).
The code-to-text mappings are the union of the OceanStor Dorado 6.1.0 and
V700R001C10 REST Interface References. A single appliance only ever reports the
subset its own firmware knows, so the union lets every documented code render a
readable label instead of `'Unknown'`, regardless of which firmware answers.
"""
# The module is long because it transcribes the vendor's enumeration tables, not
# because of branching; splitting it would only scatter that data.
# pylint: disable=C0302
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026082501'
import json
from time import sleep as _sleep
from . import base, cache, time, url
from .globals import STATE_CRIT, STATE_OK, STATE_WARN
# Own cache file, following `lib.redfish`. The shared default file is written by every
# consumer on the host, and `lib.cache` sweeps expired rows on the read path, so a session
# token that is read on every single run would sit in the middle of that lock traffic.
CACHE_FILENAME = 'linuxfabrik-monitoring-plugins-huawei-dorado.db'
# Bytes per sector, for the capacities the API counts in sectors. The appliance reports its
# own value as `SECTORSIZE` in the `system/` response, and that is what a consumer should
# hand to `sectors2bytes()` where it has it. This default covers the consumers that do not
# query `system/` just to learn it, and it is what every documented response example shows.
# It is not the per-LUN `SECTORSIZE`, which is the block size a LUN exposes to the host and
# is a different number on the same appliance.
DEFAULT_SECTOR_SIZE = 512
# The device ID the vendor's own login example sends. The appliance accepts any string here
# and answers with the real one, so a consumer that does not know its appliance's ID logs in
# with this placeholder and reads `data.deviceid` out of the response.
DEVICE_ID_PLACEHOLDER = 'xxxxx'
# Field names whose value never reaches a `--verbose` dump, whatever the appliance sends
# back. The login response is not recorded at all, so a session token cannot get in
# through the front door; this closes the back door of a firmware echoing one inside a
# data response. Matched case-insensitively.
_REDACTED_FIELDS = frozenset(
{
'cookie',
'ibasetoken',
'password',
'set-cookie',
'x-auth-token',
'x_auth_token',
'x_csrf_token',
}
)
# What the appliance answered, per endpoint, for `--verbose`. Filled by `get_data()` only
# when the caller asked for it, so a normal run carries no copy of every response.
_recorded_responses = []
def _redact(value):
"""
Return a copy of an API response with every sensitive field's value replaced.
Parameters
----------
value : any
A decoded response, or any part of one.
Returns
-------
any
The same structure, with the value of every field named in `_REDACTED_FIELDS`
replaced by `'******'`.
"""
if isinstance(value, dict):
return {
key: ('******' if str(key).lower() in _REDACTED_FIELDS else _redact(inner))
for key, inner in value.items()
}
if isinstance(value, list):
return [_redact(item) for item in value]
return value
def record_response(endpoint, result):
"""
Remember what an endpoint answered, so a consumer can print it under `--verbose`.
Parameters
----------
endpoint : str
The endpoint that was queried, as it was requested.
result : dict
The response envelope, as `get_data()` built it.
Notes
-----
- Called by `get_data()` and only when the caller set `VERBOSE`, so a normal run
does not keep a second copy of every response in memory.
- The login response is deliberately never recorded. It is the one response that
carries a session token, and a token in the output is a credential in whatever
stores, forwards and logs that output downstream.
"""
_recorded_responses.append((endpoint, _redact(result)))
def format_responses():
"""
Render everything `record_response()` collected, for a `--verbose` output.
Returns
-------
str
One block per request, naming the endpoint and pretty-printing what came back.
Empty when nothing was recorded, which is the case on a normal run and in test mode.
Notes
-----
- Meant for working out what an appliance actually reports, so a consumer can be built
against it. The output is as long as the appliance's answers are, which on a list
endpoint of a large array is very long indeed. It is a command-line tool, not
something to switch on in a service definition.
Examples
--------
>>> print(format_responses())
### GET controller
{
"data": [
...
],
"error": {"code": 0}
}
"""
blocks = []
for endpoint, result in _recorded_responses:
blocks.append(
f'### GET {endpoint}\n{json.dumps(result, indent=2, sort_keys=True)}'
)
return '\n\n'.join(blocks)
def _with_recorded_responses(message):
"""
Append the recorded responses to an abort message, where any were recorded.
Nothing is recorded unless the caller asked for verbose output, so a normal run gets
the message unchanged and a verbose one gets the answers that explain it.
Parameters
----------
message : str
The message the consumer is about to abort with.
Returns
-------
str
The message, followed by what every request returned.
"""
recorded = format_responses()
return f'{message}\n\n{recorded}' if recorded else message
def assert_ok(result, what):
"""
Abort the calling process (UNKNOWN) unless the appliance reported success.
Every consumer has to check the response envelope before it reads anything out of it,
and that check is easy to get subtly wrong: the appliance reports success as the number
`0` on some endpoints and as the string `'0'` on others, and a response that carries no
`error` object at all turns a naive `result['error']['code']` into an `AttributeError`
instead of a clean UNKNOWN.
Parameters
----------
result : dict
A response envelope as `get_data()` returns it.
what : str
What was being queried, as a noun phrase for the message ("the fans", "the storage
pools"). It is the only part of the output that tells an operator which of a caller's
several requests failed.
Returns
-------
None
Returns on success, and does not return otherwise.
Notes
-----
- An empty response is an error as well. `get_data()` always answers with an envelope,
so nothing at all means the consumer never reached the appliance.
- The appliance's own description and suggestion are printed where it sends them. They
name the cause far better than anything a consumer could infer from the code.
- Under verbose output the appliance's answers are appended to the abort message. This
is the moment they are needed most, and printing them at the end of a successful run
only would hide them from exactly the run that has to be explained.
Examples
--------
>>> assert_ok({'error': {'code': 0}, 'data': []}, 'the fans')
"""
if not result:
base.cu(_with_recorded_responses('Got no response from the appliance.'))
code = get_error_code(result)
if code in (0, '0'):
return
error = result.get('error') if isinstance(result, dict) else None
if not isinstance(error, dict):
error = {}
description = error.get('description') or 'no description'
suggestion = error.get('suggestion') or ''
base.cu(
_with_recorded_responses(
f'Failed to query {what} (code {code}): {description} {suggestion}'.strip()
)
)
def as_code(value):
"""
Normalise an API status code into an `int`, or `None` if it is unusable.
The appliance reports its codes as strings, and a field may be missing entirely, in which
case the caller hands in `None` (`data.get('HEALTHSTATUS')`). A missing or malformed code
has to render as `'Unknown'`; aborting the calling process with a `TypeError` or
`ValueError` would turn a single unexpected field into a crashed check.
Parameters
----------
value : any
The raw field value taken from the API response.
Returns
-------
int or None
The code as an integer, or `None` if it cannot be converted.
Examples
--------
>>> as_code('27')
27
>>> as_code(None) is None
True
"""
try:
return int(value)
except (TypeError, ValueError):
return None
def as_temperature(value):
"""
Normalise a reported temperature into an `int`, or `None` if it is not a reading.
A component without a temperature sensor still reports the field. Which placeholder it
uses depends on the object: a controller answers with `-1`, a power module and a disk
with `0`, and a firmware may leave the field out altogether. None of the three is a
measurement, and all three have to be kept out of the performance data and away from
the thresholds.
Parameters
----------
value : any
The raw field value taken from the API response.
Returns
-------
int or None
The temperature in degrees Celsius, or `None` when the object
reported no reading.
Notes
-----
- The placeholders are what the vendor's own response examples show: the controller
example of the V700R001C10 REST Interface Reference carries `"TEMPERATURE": "-1"`,
the power module example `"TEMPERATURE": "0"`.
- `-1` in particular must not reach a threshold. A Nagios range such as `55` means
`0:55`, so a value below zero is outside it, and a controller with no sensor would
report CRITICAL as soon as an operator sets a temperature threshold at all.
- Zero is discarded rather than kept as a reading. An appliance sitting at or below
0 °C is outside every documented operating range, so the value is a placeholder
rather than a measurement worth graphing.
Examples
--------
>>> as_temperature('42')
42
>>> as_temperature('-1') is None
True
>>> as_temperature('0') is None
True
"""
code = as_code(value)
if code is None or code <= 0:
return None
return code
def field(data, *names, default=None):
"""
Read a field whose name the appliance spells differently depending on the firmware.
Most objects report their fields in upper case (`HEALTHSTATUS`), a few in camelCase
(`healthStatus`), and the `sfp` object is documented both ways on the same page of the
REST Interface Reference: its parameter table lists `rxPowerReal` and `sfpModeType`
while its response example shows `RXPOWER` and `SFPMODETYPE`. A consumer that picks one
spelling reads an empty field on half the fleet.
Parameters
----------
data : dict
One object as the API returned it.
*names : str
The field names to try, in order of preference.
default : any, optional
What to return when none of them is present.
Returns
-------
any
The first value found under any of `names`, matched case-insensitively, or `default`.
Notes
-----
- The exact spellings are tried first and in the order given, so a firmware that reports
two of the names (an old field and its replacement) still yields the preferred one.
Only then does the case-insensitive pass run.
- A key that is present but empty counts as found. The appliance uses `'--'` and `''`
for "no value", and turning those into the default would hide the difference between
a field a firmware does not have and one it has nothing to say about.
Examples
--------
>>> field({'RXPOWER': '[1234]'}, 'rxPowerReal', 'RXPOWER')
'[1234]'
>>> field({'healthStatus': '1'}, 'HEALTHSTATUS')
'1'
>>> field({}, 'MODEL', default='--')
'--'
"""
for name in names:
if name in data:
return data[name]
lowered = {str(key).lower(): key for key in data}
for name in names:
key = lowered.get(str(name).lower())
if key is not None:
return data[key]
return default
def get_account_state(st):
"""
Convert a Huawei password status code into a human-readable description.
The appliance reports this as `accountstate` in the login response. It describes the state
of the login account's password, not the outcome of the login itself: a login can succeed
while the account is still unusable for anything but changing the password.
Parameters
----------
st : int or str
The password status code.
A missing or malformed value renders as `'Unknown'`.
Returns
-------
str
A human-readable description including the original code in brackets.
Returns `'Unknown'` if the code is not recognized.
Examples
--------
>>> get_account_state(3)
'password expired (3)'
"""
mapping = {
1: 'normal (1)',
3: 'password expired (3)',
4: 'initial password, which must be reset (4)',
5: 'password about to expire (5)',
6: 'password must be changed upon the next login (6)',
7: 'password never expires (7)',
8: 'email one-time password authentication required (8)',
9: 'first login, password must be initialized (9)',
10: 'RADIUS one-time password authentication required (10)',
11: 'RADIUS challenge response required (11)',
}
return mapping.get(as_code(st), 'Unknown')
def get_alarm_severity(sev):
"""
Convert a Huawei alarm severity code into a human-readable description.
Parameters
----------
sev : int or str
The alarm severity code.
A missing or malformed value renders as `'Unknown'`.
Returns
-------
str
A human-readable description including the original code in brackets.
Returns `'Unknown'` if the code is not recognized.
Notes
-----
- `alarm/currentalarm` documents only warning, major and critical. Informational (`2`)
comes from the alarm type table, where an appliance may define an alarm at that
severity, so it is carried here: a listing that does contain one has to render it
rather than fall through to `'Unknown'`.
- Code `4` (minor) exists on other Huawei product lines and is deliberately absent,
because neither Dorado REST Interface Reference lists it.
Examples
--------
>>> get_alarm_severity(6)
'Critical (6)'
"""
mapping = {
2: 'Informational (2)',
3: 'Warning (3)',
5: 'Major (5)',
6: 'Critical (6)',
}
return mapping.get(as_code(sev), 'Unknown')
def get_alarm_severity_state(sev):
"""
Convert a Huawei alarm severity code into the state a consumer reports for it.
Parameters
----------
sev : int or str
The alarm severity code.
Returns
-------
int
`STATE_OK` for an informational alarm, `STATE_CRIT` for a critical one, `STATE_WARN`
for warning, major, a code the enumeration does not know and a missing value.
Notes
-----
- An informational alarm is a note, not a fault, so it does not alert. It still appears
in the output, where it is what an operator reads after the fact.
- Major sits at warning rather than at critical on purpose: it marks a fault that
degrades the array without stopping it, and an array full of major alarms that all
page someone at night is an array nobody watches any more.
Examples
--------
>>> get_alarm_severity_state(6) == STATE_CRIT
True
>>> get_alarm_severity_state('2') == STATE_OK
True
"""
code = as_code(sev)
if code == 2:
return STATE_OK
if code == 6:
return STATE_CRIT
return STATE_WARN
def get_all_data(endpoint, args, page_size=100, max_pages=100):
"""
Fetch every object of a list endpoint, one page at a time.
Most list endpoints return at most 100 objects per request and expect the caller to page
through the rest. Without paging an array simply stops being reported past the first page,
which reads as a smaller but healthy inventory: exactly the failure a consumer must not have.
Parameters
----------
endpoint : str
The endpoint after the device ID, optionally with a query string of its own (for example
`alarm/currentalarm?filter=level::6`). The `range` parameter is appended to it, so the
caller must not supply one.
args : object
The same object `get_data()` reads.
page_size : int, optional
Objects to request per page. The documented ceiling differs per endpoint: 100 for most,
250 for the alarm and event endpoints, 10000 for storage pools. Staying at or below the
ceiling is the caller's job, because the appliance rejects a larger range rather than
capping it.
max_pages : int, optional
Hard stop on the number of requests. It bounds the runtime of a call against an array
with far more objects than anyone expected, and it keeps a firmware that ignores `range`
from looping forever.
Returns
-------
tuple (dict, bool)
The envelope of the last request with its `data` replaced by every object collected, and
a flag that is `True` when `max_pages` cut the walk short. On a failed request the
envelope is handed back unchanged with the flag `False`, so the caller reports the
appliance's own error text the same way it would after a single `get_data()`.
Notes
-----
- `range=[i-j]` is half-open: the appliance documents it as "objects subscripted in
sequence from i to j-1". Consecutive pages therefore start where the previous one
ended, and no object is read twice.
- A page shorter than `page_size` ends the walk: the appliance has nothing left to hand
out. A page of exactly `page_size` objects is always followed by another request, which
costs one empty request when the object count is an exact multiple of the page size.
- Only some list endpoints take `range` at all. Both REST Interface References
document it for `host`, `HyperMetroDomain`, `HyperMetroPair`, `lun` and
`storagepool`, and for the alarm and event endpoints. The hardware inventory
endpoints (`backup_power`, `controller`, `disk`, `enclosure`, `expboard`, `fan`,
`intf_module`, `power`, `sfp`) and the port endpoints (`fc_port`, `eth_port`,
`sas_port` and their relatives) do not. Query those with `get_data()` instead:
asking them for a range either errors out or silently returns the full list on
every iteration, which past `page_size` objects turns one walk into `max_pages`
copies of the same inventory.
- The truncation flag is deliberately returned rather than turned into an abort here.
Whether an incomplete inventory is worth an UNKNOWN or just a note in the output depends
on the caller, and this function has no way to tell.
Examples
--------
>>> result, truncated = get_all_data('lun', args)
>>> len(result['data'])
237
"""
separator = '&' if '?' in endpoint else '?'
collected = []
result = {}
truncated = False
for page in range(max_pages):
start = page * page_size
result = get_data(
f'{endpoint}{separator}range=[{start}-{start + page_size}]',
args,
)
if get_error_code(result) not in (0, '0'):
# Hand the failed envelope back whole. Reporting the objects collected so far
# would present a partial inventory as a complete one.
return result, False
data = result.get('data') or []
if not isinstance(data, list):
# A single object rather than a list means this endpoint does not page at all.
return result, False
collected += data
if len(data) < page_size:
break
else:
truncated = True
result['data'] = collected
return result, truncated
def get_controller_model(cm):
"""
Convert a Huawei controller model code into a human-readable description.
This function translates numeric controller model codes from Huawei storage systems into
descriptive text for better hardware identification.
Parameters
----------
cm : int or str
The controller model code to interpret.
A missing or malformed value renders as `'Unknown'`.
Returns
-------
str
A human-readable description of the controller model.
Returns `'Unknown'` if the code is not recognized.
Examples
--------
>>> get_controller_model(4127)
'2U2C PALM control board'
>>> get_controller_model('4144')
'2U2C NVMe control board'
"""
mapping = {
4127: '2U2C PALM control board',
4128: '2U2C SAS control board',
4129: '2U2C SAS control board (Hi1620S)',
4132: '4U4C control board',
4135: '2U2C PALM 1711 control board',
4136: '2U2C SAS 1711 control board',
4137: '2U2C SAS 1711 control board (Hi1620S)',
4140: '4U4C 1711 control board',
4141: '2U2C SAS 1711 control board (100GE extension board)',
4142: '2U2C SAS control board (100GE extension board)',
4144: '2U2C NVMe control board',
4149: '4U2C 1711 control board',
4158: '8U2C control board',
4161: '2U2C PALM control board',
4162: '2U2C PALM control board',
4165: '2U2C 2P control board (100G extension board)',
4166: '2P1 2U2C PALM control board (100GE extension board)',
4167: '2P1 2U2C SAS control board (100GE extension board)',
4168: '2P1 2U2C SAS control board (SAS extension board)',
4169: '1P2 2U2C SAS control board (100GE extension board)',
4170: '1P2 2U2C PALM control board (100GE extension board)',
4174: '4U4C4P control board',
}
return mapping.get(as_code(cm), 'Unknown')
def get_controller_role(role):
"""
Convert a controller's `ROLE` code into a human-readable description.
Parameters
----------
role : int or str
The role code to interpret.
A missing or malformed value renders as `'Unknown'`.
Returns
-------
str
A human-readable description of the role.
Returns `'Unknown'` if the code is not recognized.
Notes
-----
- Scoped to the controller object on purpose. `ROLE` is reused with entirely different
meanings elsewhere: on a logical port `1` is a management port, on a HyperMetro domain
`0` is the preferred site. Applied to those objects this mapping would print a
confident but wrong label, which is why the function name names its object.
Examples
--------
>>> get_controller_role(1)
'Primary'
>>> get_controller_role('2')
'Secondary'
"""
mapping = {
0: 'Member',
1: 'Primary',
2: 'Secondary',
}
return mapping.get(as_code(role), 'Unknown')
def get_cp_type(cp):
"""
Convert a consistency protection (CP) type code into a human-readable description.
This function translates numeric CP type codes from Huawei storage systems into descriptive
labels that indicate the type of quorum mechanism in use.
Parameters
----------
cp : int or str
The CP type code to interpret.
A missing or malformed value renders as `'Unknown'`.
Returns
-------
str
A human-readable description of the consistency protection type.
Returns `'Unknown'` if the code is not recognized.
Examples
--------
>>> get_cp_type(1)
'Quorum Server'
>>> get_cp_type('2')
'Quorum Disk'
"""
mapping = {
1: 'Quorum Server',
2: 'Quorum Disk',
3: 'None',
}
return mapping.get(as_code(cp), 'Unknown')
# Password states that make a login pointless to continue from. Two groups qualify: an
# expired password, where the account is out of its validity period, and a login that is
# not finished at all because the appliance still waits for a one-time password or a
# challenge response. In the second group no session exists yet to query anything with.
#
# Everything else is deliberately left out. Neither REST Interface Reference states which
# password states restrict a session, so every entry here is an assumption about the
# appliance rather than a documented rule. States 4 (initial password) and 6 (must be
# changed at the next login) used to abort as well, which took every consumer on a freshly
# deployed appliance to UNKNOWN before anyone had logged in to set a password. They now
# run: if the appliance really does refuse their requests, `get_data()` reports its error
# text, which names the cause better than a guess made here. This matches
# `lib.huawei_pacific`, whose `_UNUSABLE_PASSWORD_STATES` covers the same ground.
_UNUSABLE_ACCOUNT_STATES = frozenset({3, 8, 9, 10, 11})
def _cached_session(session_key):
"""
Read a cached `(iBaseToken, Cookie, deviceId)` triple, or `None` if there is no usable one.
Used by `get_creds()`.
Parameters
----------
session_key : str
The cache key the triple is stored under.
Returns
-------
tuple (str, str, str) or None
The triple, or `None` if the cache holds
nothing, something an older version wrote, or a half-populated entry. All three cases
have the same answer: log in again rather than build a request header out of it.
Notes
-----
- An entry of any other length is discarded rather than padded. An older version stored
the token pair alone, and a device ID guessed for such an entry would send every
request of the run to the wrong path.
"""
cached = cache.get(session_key, filename=CACHE_FILENAME)
if not cached:
return None
try:
session = json.loads(cached)
except (TypeError, ValueError):
return None
if isinstance(session, list) and len(session) == 3 and all(session):
return session[0], session[1], session[2]
return None
def _logout(args, session):
"""
End a session on the appliance, ignoring whatever comes back.
Called on a session nothing will read back: the one a forced re-login replaces in
`get_creds()`, and every session at all in `get_data()` when caching is switched off.
Without it such a session stays open until the appliance's own timeout expires it, which
is 20 minutes by default. The appliance caps the sessions it holds system-wide at 32 by
default (256 is the only other value `CHANGE_USER_LOGIN_MAX_SESSIONS` accepts), so a
check that keeps failing would leave orphans behind run after run and, at a one-minute
interval, fill that pool from a single service. Once it is full every login is refused,
including an operator's login to DeviceManager.
Parameters
----------
args : object
An object containing `URL`, `INSECURE`, `NO_PROXY` and `TIMEOUT`.
session : tuple (str, str, str)
The `(iBaseToken, Cookie, deviceId)`
triple to end.
Returns
-------
None
Notes
-----
- The device ID comes from the session being ended, not from `args`. A session opened
without a caller-supplied device ID carries the one the appliance answered with, and
that is the only path the logout is accepted on.
- Every outcome is discarded, errors included. This is housekeeping on the way to a fresh
login, and the session most likely to be logged out here is one the appliance has
already dropped, which is exactly the case that answers with an error. Letting that
abort the re-login would break the recovery path this call is part of. `url.fetch()`
reports failures through its return value rather than by raising, so no exception can
escape either.
"""
ibasetoken, cookie, device_id = session
url.fetch(
f'{args.URL}/deviceManager/rest/{device_id}/sessions',
# No `Content-Type`: this is a bare verb with no body, and announcing a JSON one
# that is not there is what `get_data()` avoids for the same reason.
header={
'Cookie': cookie,
'iBaseToken': ibasetoken,
},
insecure=args.INSECURE,
method='DELETE',
no_proxy=args.NO_PROXY,
proxy=getattr(args, 'PROXY', None),
timeout=args.TIMEOUT,
)
def get_creds(args, force_relogin=False):
"""
Retrieve and cache Huawei appliance credentials.
This function handles authentication against a Huawei device API. It reuses cached tokens
(`iBaseToken` and `cookie`) if available to avoid repeated logins, which may be rate-limited for
security reasons. If no cached credentials are found, it performs a login request and caches
the new credentials for future reuse.
Parameters
----------
args : object
An argument object containing:
- `URL` (`str`): Base URL of the Huawei API.
- `DEVICE_ID` (`str`): Unique device identifier. May be left empty, in which case the
login is sent to the placeholder path the vendor's own example uses and the
appliance answers with its real device ID.
- `USERNAME` (`str`): Login username.
- `PASSWORD` (`str`): Login password.
- `SCOPE` (`str`): User type (`'0'` local user, `'1'` LDAP user, `'8'` RADIUS user).
`'8'` is only documented from V700R001C10 on. The value is passed through
unvalidated, so a firmware that knows further types works without a code change.
- `INSECURE` (`bool`): Whether to disable SSL verification.
- `NO_PROXY` (`bool`): Whether to ignore proxy settings.
- `TIMEOUT` (`int`): Request timeout in seconds.
- `CACHE_EXPIRE` (`int`): Cache expiration time in minutes.
force_relogin : bool, optional
If `True`, ignore any cached token and perform a fresh login, overwriting the cache.
Used to recover from a cached session that the appliance no longer accepts (for example
after a controller reboot, a manual session reset, or the server-side 20-minute timeout).
Returns
-------
tuple (str, str, str)
- `ibase_token` (str): The API session token (iBaseToken).
- `cookie` (str): The session cookie.
- `device_id` (str): The device ID every further request is addressed to.
Notes
-----
- Token, cookie and device ID are stored together, JSON-encoded, under the single cache
key `huaweidorado-{URL}-{USERNAME}-session`, in the module's own cache file. One key
rather than three because a request needs all of them: split over several keys, a write
that only partly succeeds leaves a cache that can never be reused, and the resulting
login on every single run is exactly what the caching is there to avoid.
The user name is part of the key because a session carries that user's role: without it
a consumer running as a different account would silently reuse the first account's session
and query the appliance with the wrong privileges. The device ID is deliberately not
part of it: it is optional, and the appliance accepts any string for it on the initial
login, so it does not identify an appliance on its own. The URL does.
- The device ID does not have to be supplied. The login is documented against the
placeholder path `/deviceManager/rest/xxxxx/sessions`, and the response carries the
appliance's own `deviceid`, which is what the rest of the run then uses. A caller that
does supply one keeps it, so an appliance that answers with something unexpected can
still be addressed explicitly.
- A `CACHE_EXPIRE` of `0` turns caching off, rather than writing an entry that expires a
moment later. Every call then logs in, which is what an operator asks for by setting it.
- If login is required, the request is sent as serialized JSON with headers.
- A rejected login aborts the caller (UNKNOWN) instead of returning an empty token.
The appliance answers a wrong password, an expired password or a locked account with
HTTP 200 and a non-zero `error.code`, so without this check the empty token would travel
into the next request header and surface as an unrelated type error. Failing here also
keeps a wrong password from being replayed, which would drive the account towards the
appliance's lockout threshold.
- An accepted login whose `accountstate` marks the password as expired aborts the caller,
as does one that still waits for a one-time password or a challenge response: the
account is past its validity period, or the login never finished. Which password states
actually restrict a session is not documented, so no other state is treated as fatal;
see `_UNUSABLE_ACCOUNT_STATES`.
- No logout is sent at the end of a run. Because the token is cached and reused across
runs, that would force a login on every single run and multiply the login rate the
appliance sees. The session that `force_relogin` replaces is a different matter and is
handed back through `_logout()`, so it does not sit in the appliance's session pool
until its own timeout expires it.
- The complete `Set-Cookie` field is sent back as the `Cookie` request header, attributes
and all, because that is what the vendor's own example does. The limit of that approach
is an appliance sending more than one `Set-Cookie` header: `lib.url` exposes the response
headers as a flat mapping, in which repeated fields arrive comma-joined and can no longer
be told apart. Should a firmware ever do that, `lib.url` has to expose the raw header list
first.
Examples
--------
>>> ibasetoken, cookie, device_id = get_creds(args)
"""
session_key = f'huaweidorado-{args.URL}-{args.USERNAME}-session'
caching = args.CACHE_EXPIRE > 0
if caching:
cached = _cached_session(session_key)
if cached and not force_relogin:
return cached
if cached:
# About to replace this session, so hand it back to the appliance instead of
# leaving it to occupy a slot in the session pool until its timeout expires.
_logout(args, cached)
login_device_id = getattr(args, 'DEVICE_ID', '') or DEVICE_ID_PLACEHOLDER
uri = f'{args.URL}/deviceManager/rest/{login_device_id}/sessions'
header = {'Content-Type': 'application/json'}
data = {
'username': args.USERNAME,
'password': args.PASSWORD,
'scope': args.SCOPE,
}
result = base.coe(
url.fetch_json(
uri,
data=data,
encoding='serialized-json',
extended=True,
header=header,
insecure=args.INSECURE,
no_proxy=args.NO_PROXY,
proxy=getattr(args, 'PROXY', None),
timeout=args.TIMEOUT,
)
)
response_json = result.get('response_json')
if not isinstance(response_json, dict):
base.cu(
f'Login at {args.URL} returned {type(response_json).__name__} instead of the '
'documented response object.'
)
session_data = response_json.get('data')
if not isinstance(session_data, dict):
session_data = {}
ibasetoken = session_data.get('iBaseToken')
# lib.url lower-cases all response header names (RFC 9110, section 5.1).
cookie = result.get('response_header', {}).get('set-cookie')
if not ibasetoken or not cookie:
error = response_json.get('error') or {}
# Both halves are required to build the request header, so a response carrying only
# one of them is as unusable as one carrying neither. The fallback text has to cover
# that case too, rather than naming the token alone.
base.cu(
f'Login at {args.URL} failed: '
f'{error.get("description") or "incomplete session in the login response"} '
f'(code {error.get("code", "n/a")}).'
)
accountstate = as_code(session_data.get('accountstate'))
if accountstate in _UNUSABLE_ACCOUNT_STATES:
base.cu(
f'Login at {args.URL} succeeded, but the account cannot query anything: '
f'{get_account_state(accountstate)}.'
)
# A caller-supplied device ID wins. It is the one an operator can look up in
# DeviceManager, so an appliance whose answer does not match it is still addressable.
device_id = getattr(args, 'DEVICE_ID', '') or session_data.get('deviceid')
if not device_id:
base.cu(
f'Login at {args.URL} succeeded, but the response carries no device ID and none '
'was supplied. Pass the appliance device ID explicitly.'
)
if caching:
cache.set(
session_key,
json.dumps([ibasetoken, cookie, device_id]),
time.now() + args.CACHE_EXPIRE * 60,
filename=CACHE_FILENAME,
)
return ibasetoken, cookie, device_id
def _as_envelope(success, response):
"""
Normalise whatever `url.fetch_json()` returned into the documented response envelope.
`get_data()` promises its caller a `{'error': {'code': ...}, 'data': ...}` document. Three
things can arrive instead: a transport failure (the message string), an HTTP error status
(the unparsed response body, because `url.fetch_json()` only decodes JSON on success), and
an appliance answering with something other than a JSON object. Wrapping all of them in the
envelope keeps a single bad response from turning into a type error inside the retry loop,