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