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