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