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