1#!/usr/bin/env python3
2
3"""Tool to visualize PLDM PDR's"""
4
5import argparse
6import hashlib
7import json
8import os
9import shlex
10import shutil
11import subprocess
12import sys
13from datetime import datetime
14
15import paramiko
16from graphviz import Digraph
17from tabulate import tabulate
18
19
20class Process:
21    """Interface definition for interacting with a process created by an
22    Executor."""
23
24    def __init__(self, stdout, stderr):
25        """Construct a Process object.  Process object clients can read the
26        process stdout and stderr with os.read(), and can wait for the
27        process to exit.
28
29        Parameters:
30            stdout: os.read()able stream representing stdout
31            stderr: os.read()able stream representing stderr
32        """
33
34        self.stdout = stdout
35        self.stderr = stderr
36
37    def wait(self):
38        """Wait for the process to finish, and return its exit status."""
39
40        raise NotImplementedError
41
42
43class Executor:
44    """Interface definition for interacting with executors.  An executor is an
45    object that can run a program."""
46
47    def exec_command(self, cmd):
48        raise NotImplementedError
49
50    def close(self):
51        pass
52
53
54class ParamikoProcess(Process):
55    """Concrete implementation of the Process interface that adapts Paramiko
56    interfaces to the Process interface requirements."""
57
58    def __init__(self, stdout, stderr):
59        super(ParamikoProcess, self).__init__(stdout, stderr)
60
61    def wait(self):
62        return self.stderr.channel.recv_exit_status()
63
64
65class ParamikoExecutor(Executor):
66    """Concrete implementation of the Executor interface that uses
67    Paramiko to connect to a remote BMC to run the program."""
68
69    def __init__(self, hostname, uname, passwd, port, **kw):
70        """This function is responsible for connecting to the BMC via
71        ssh and returning an executor object.
72
73        Parameters:
74            hostname: hostname/IP address of BMC
75            uname: ssh username of BMC
76            passwd: ssh password of BMC
77            port: ssh port of BMC
78        """
79
80        super(ParamikoExecutor, self).__init__()
81        self.client = paramiko.SSHClient()
82        self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
83        self.client.connect(
84            hostname, username=uname, password=passwd, port=port, **kw
85        )
86
87    def exec_command(self, cmd):
88        _, stdout, stderr = self.client.exec_command(cmd)
89        return ParamikoProcess(stdout, stderr)
90
91    def close(self):
92        self.client.close()
93
94
95class SubprocessProcess(Process):
96    def __init__(self, popen):
97        self.popen = popen
98        super(SubprocessProcess, self).__init__(popen.stdout, popen.stderr)
99
100    def wait(self):
101        self.popen.wait()
102        return self.popen.returncode
103
104
105class SubprocessExecutor(Executor):
106    def __init__(self):
107        super(SubprocessExecutor, self).__init__()
108
109    def exec_command(self, cmd):
110        args = shlex.split(cmd)
111        args[0] = shutil.which(args[0])
112        p = subprocess.Popen(
113            args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
114        )
115        return SubprocessProcess(p)
116
117
118def prepare_summary_report(state_sensor_pdr, state_effecter_pdr):
119    """This function is responsible to parse the state sensor pdr
120    and the state effecter pdr dictionaries and creating the
121    summary table.
122
123    Parameters:
124        state_sensor_pdr: list of state sensor pdrs
125        state_effecter_pdr: list of state effecter pdrs
126
127    """
128
129    summary_table = []
130    headers = ["sensor_id", "entity_type", "state_set", "states"]
131    summary_table.append(headers)
132    for value in state_sensor_pdr.values():
133        summary_record = []
134        sensor_possible_states = ""
135        for sensor_state in value["possibleStates[0]"]:
136            sensor_possible_states += sensor_state + "\n"
137        summary_record.extend(
138            [
139                value["sensorID"],
140                value["entityType"],
141                value["stateSetID[0]"],
142                sensor_possible_states,
143            ]
144        )
145        summary_table.append(summary_record)
146    print("Created at : ", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
147    print(tabulate(summary_table, tablefmt="fancy_grid", headers="firstrow"))
148
149    summary_table = []
150    headers = ["effecter_id", "entity_type", "state_set", "states"]
151    summary_table.append(headers)
152    for value in state_effecter_pdr.values():
153        summary_record = []
154        effecter_possible_states = ""
155        for state in value["possibleStates[0]"]:
156            effecter_possible_states += state + "\n"
157        summary_record.extend(
158            [
159                value["effecterID"],
160                value["entityType"],
161                value["stateSetID[0]"],
162                effecter_possible_states,
163            ]
164        )
165        summary_table.append(summary_record)
166    print(tabulate(summary_table, tablefmt="fancy_grid", headers="firstrow"))
167
168
169def draw_entity_associations(pdr, counter):
170    """This function is responsible to create a picture that captures
171    the entity association hierarchy based on the entity association
172    PDR's received from the BMC.
173
174    Parameters:
175        pdr: list of entity association PDR's
176        counter: variable to capture the count of PDR's to unflatten
177                 the tree
178
179    """
180
181    dot = Digraph(
182        "entity_hierarchy",
183        node_attr={"color": "lightblue1", "style": "filled"},
184    )
185    dot.attr(
186        label=r"\n\nEntity Relation Diagram < "
187        + str(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
188        + ">\n"
189    )
190    dot.attr(fontsize="20")
191    edge_list = []
192    for value in pdr.values():
193        parentnode = str(value["containerEntityType"]) + str(
194            value["containerEntityInstanceNumber"]
195        )
196        dot.node(
197            hashlib.md5(
198                (
199                    parentnode + str(value["containerEntityContainerID"])
200                ).encode()
201            ).hexdigest(),
202            parentnode,
203        )
204
205        for i in range(1, value["containedEntityCount"] + 1):
206            childnode = str(value[f"containedEntityType[{i}]"]) + str(
207                value[f"containedEntityInstanceNumber[{i}]"]
208            )
209            cid = str(value[f"containedEntityContainerID[{i}]"])
210            dot.node(
211                hashlib.md5((childnode + cid).encode()).hexdigest(), childnode
212            )
213
214            if [
215                hashlib.md5(
216                    (
217                        parentnode + str(value["containerEntityContainerID"])
218                    ).encode()
219                ).hexdigest(),
220                hashlib.md5((childnode + cid).encode()).hexdigest(),
221            ] not in edge_list:
222                edge_list.append(
223                    [
224                        hashlib.md5(
225                            (
226                                parentnode
227                                + str(value["containerEntityContainerID"])
228                            ).encode()
229                        ).hexdigest(),
230                        hashlib.md5((childnode + cid).encode()).hexdigest(),
231                    ]
232                )
233                dot.edge(
234                    hashlib.md5(
235                        (
236                            parentnode
237                            + str(value["containerEntityContainerID"])
238                        ).encode()
239                    ).hexdigest(),
240                    hashlib.md5((childnode + cid).encode()).hexdigest(),
241                )
242    unflattentree = dot.unflatten(stagger=(round(counter / 3)))
243    unflattentree.render(
244        filename="entity_association_"
245        + str(datetime.now().strftime("%Y-%m-%d_%H-%M-%S")),
246        view=False,
247        cleanup=True,
248        format="pdf",
249    )
250
251
252class PLDMToolError(Exception):
253    """Exception class intended to be used to hold pldmtool invocation failure
254    information such as exit status and stderr.
255
256    """
257
258    def __init__(self, status, stderr):
259        msg = "pldmtool failed with exit status {}.\n".format(status)
260        msg += "stderr: \n\n{}".format(stderr)
261        super(PLDMToolError, self).__init__(msg)
262        self.status = status
263
264    def get_status(self):
265        return self.status
266
267
268def process_pldmtool_output(process):
269    """Ensure pldmtool runs without error and if it does fail, detect that and
270    show the pldmtool exit status and it's stderr.
271
272    A simpler implementation would just wait for the pldmtool exit status
273    prior to attempting to decode it's stdout.  Instead, optimize for the
274    no error case and allow the json decoder to consume pldmtool stdout as
275    soon as it is available (in parallel).  This results in the following
276    error scenarios:
277        - pldmtool fails and the decoder fails
278          Ignore the decoder fail and throw PLDMToolError.
279        - pldmtool fails and the decoder doesn't fail
280          Throw PLDMToolError.
281        - pldmtool doesn't fail and the decoder does fail
282          This is a pldmtool bug - re-throw the decoder error.
283
284    Parameters:
285        process: A Process object providing process control functions like
286                 wait, and access functions such as reading stdout and
287                 stderr.
288
289    """
290
291    status = 0
292    try:
293        data = json.load(process.stdout)
294        # it's unlikely, but possible, that pldmtool failed but still wrote a
295        # valid json document - so check for that.
296        status = process.wait()
297        if status == 0:
298            return data
299    except json.decoder.JSONDecodeError:
300        # pldmtool wrote an invalid json document.  Check to see if it had
301        # non-zero exit status.
302        status = process.wait()
303        if status == 0:
304            # pldmtool didn't have non zero exit status, so it wrote an invalid
305            # json document and the JSONDecodeError is the correct error.
306            raise
307
308    # pldmtool had a non-zero exit status, so throw an error for that, possibly
309    # discarding a spurious JSONDecodeError exception.
310    raise PLDMToolError(status, "".join(process.stderr))
311
312
313def get_pdrs_one_at_a_time(executor):
314    """Using pldmtool, generate (record handle, PDR) tuples for each record in
315    the PDR repository.
316
317    Parameters:
318        executor: executor object for running pldmtool
319
320    """
321
322    command_fmt = "pldmtool platform getpdr -d {}"
323    record_handle = 0
324    while True:
325        process = executor.exec_command(command_fmt.format(str(record_handle)))
326        pdr = process_pldmtool_output(process)
327        yield record_handle, pdr
328        record_handle = pdr["nextRecordHandle"]
329        if record_handle == 0:
330            break
331
332
333def get_all_pdrs_at_once(executor):
334    """Using pldmtool, generate (record handle, PDR) tuples for each record in
335    the PDR repository.  Use pldmtool platform getpdr --all.
336
337    Parameters:
338        executor: executor object for running pldmtool
339
340    """
341
342    process = executor.exec_command("pldmtool platform getpdr -a")
343    all_pdrs = process_pldmtool_output(process)
344
345    # Explicitly request record 0 to find out what the real first record is.
346    process = executor.exec_command("pldmtool platform getpdr -d 0")
347    pdr_0 = process_pldmtool_output(process)
348    record_handle = pdr_0["recordHandle"]
349
350    while True:
351        for pdr in all_pdrs:
352            if pdr["recordHandle"] == record_handle:
353                yield record_handle, pdr
354                record_handle = pdr["nextRecordHandle"]
355                if record_handle == 0:
356                    return
357        raise RuntimeError(
358            "Dangling reference to record {}".format(record_handle)
359        )
360
361
362def get_pdrs(executor):
363    """Using pldmtool, generate (record handle, PDR) tuples for each record in
364    the PDR repository.  Use pldmtool platform getpdr --all or fallback on
365    getting them one at a time if pldmtool doesn't support the --all
366    option.
367
368    Parameters:
369        executor: executor object for running pldmtool
370
371    """
372    try:
373        for record_handle, pdr in get_all_pdrs_at_once(executor):
374            yield record_handle, pdr
375        return
376    except PLDMToolError as e:
377        # No support for the -a option
378        if e.get_status() != 106:
379            raise
380    except json.decoder.JSONDecodeError as e:
381        # Some versions of pldmtool don't print valid json documents with -a
382        if e.msg != "Extra data":
383            raise
384
385    for record_handle, pdr in get_pdrs_one_at_a_time(executor):
386        yield record_handle, pdr
387
388
389def fetch_pdrs_from_bmc(executor):
390    """This is the core function that would fire the getPDR pldmtool command
391    and it then agreegates the data received from all the calls into the
392    respective dictionaries based on the PDR Type.
393
394    Parameters:
395        executor: executor object for running pldmtool
396
397    """
398
399    entity_association_pdr = {}
400    state_sensor_pdr = {}
401    state_effecter_pdr = {}
402    state_effecter_pdr = {}
403    numeric_pdr = {}
404    fru_record_set_pdr = {}
405    tl_pdr = {}
406    for handle_number, my_dic in get_pdrs(executor):
407        if sys.stdout.isatty():
408            sys.stdout.write(
409                "Fetching PDR's from BMC : %8d\r" % (handle_number)
410            )
411            sys.stdout.flush()
412        if my_dic["PDRType"] == "Entity Association PDR":
413            entity_association_pdr[handle_number] = my_dic
414        if my_dic["PDRType"] == "State Sensor PDR":
415            state_sensor_pdr[handle_number] = my_dic
416        if my_dic["PDRType"] == "State Effecter PDR":
417            state_effecter_pdr[handle_number] = my_dic
418        if my_dic["PDRType"] == "FRU Record Set PDR":
419            fru_record_set_pdr[handle_number] = my_dic
420        if my_dic["PDRType"] == "Terminus Locator PDR":
421            tl_pdr[handle_number] = my_dic
422        if my_dic["PDRType"] == "Numeric Effecter PDR":
423            numeric_pdr[handle_number] = my_dic
424    executor.close()
425
426    total_pdrs = (
427        len(entity_association_pdr.keys())
428        + len(tl_pdr.keys())
429        + len(state_effecter_pdr.keys())
430        + len(numeric_pdr.keys())
431        + len(state_sensor_pdr.keys())
432        + len(fru_record_set_pdr.keys())
433    )
434    print("\nSuccessfully fetched " + str(total_pdrs) + " PDR's")
435    print("Number of FRU Record PDR's : ", len(fru_record_set_pdr.keys()))
436    print("Number of TerminusLocator PDR's : ", len(tl_pdr.keys()))
437    print("Number of State Sensor PDR's : ", len(state_sensor_pdr.keys()))
438    print("Number of State Effecter PDR's : ", len(state_effecter_pdr.keys()))
439    print("Number of Numeric Effecter PDR's : ", len(numeric_pdr.keys()))
440    print(
441        "Number of Entity Association PDR's : ",
442        len(entity_association_pdr.keys()),
443    )
444    return (
445        entity_association_pdr,
446        state_sensor_pdr,
447        state_effecter_pdr,
448        len(fru_record_set_pdr.keys()),
449    )
450
451
452def main():
453    """Create a summary table capturing the information of all the PDR's
454    from the BMC & also create a diagram that captures the entity
455    association hierarchy."""
456
457    parser = argparse.ArgumentParser(prog="pldm_visualise_pdrs.py")
458    parser.add_argument("--bmc", type=str, help="BMC IPAddress/BMC Hostname")
459    parser.add_argument("--user", type=str, help="BMC username")
460    parser.add_argument("--password", type=str, help="BMC Password")
461    parser.add_argument("--port", type=int, help="BMC SSH port", default=22)
462    args = parser.parse_args()
463
464    extra_cfg = {}
465    if args.bmc:
466        try:
467            with open(os.path.expanduser("~/.ssh/config")) as f:
468                ssh_config = paramiko.SSHConfig()
469                ssh_config.parse(f)
470                host_config = ssh_config.lookup(args.bmc)
471                if host_config:
472                    if "hostname" in host_config:
473                        args.bmc = host_config["hostname"]
474                    if "user" in host_config and args.user is None:
475                        args.user = host_config["user"]
476                    if "proxycommand" in host_config:
477                        extra_cfg["sock"] = paramiko.ProxyCommand(
478                            host_config["proxycommand"]
479                        )
480        except FileNotFoundError:
481            pass
482
483        executor = ParamikoExecutor(
484            args.bmc, args.user, args.password, args.port, **extra_cfg
485        )
486    elif shutil.which("pldmtool"):
487        executor = SubprocessExecutor()
488    else:
489        sys.exit(
490            "Can't find any PDRs: specify remote BMC with --bmc or "
491            "install pldmtool."
492        )
493
494    (
495        association_pdr,
496        state_sensor_pdr,
497        state_effecter_pdr,
498        counter,
499    ) = fetch_pdrs_from_bmc(executor)
500    draw_entity_associations(association_pdr, counter)
501    prepare_summary_report(state_sensor_pdr, state_effecter_pdr)
502
503
504if __name__ == "__main__":
505    main()
506