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