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