#!/usr/bin/python3
################################################################################
# lsdev - List devices in the system by their alias
#
# Copyright (C) 2020, Josh Boudreau <jboudreau@45drives.com>
#                     Mark Hooper   <mhooper@45drives.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
################################################################################

import json
import re
import os
import subprocess
from optparse import OptionParser
import math
from enum import Enum
import time
from table_print import *

EXIT_CODE = 0

# escape sequences for supported color options
class ANSI_colours:
	LGREEN='\033[1;32m'
	GREEN='\033[0;32m'
	RED='\033[0;31m'
	GREY='\033[1;30m'
	END='\033[0m'

class model_serial_ind:
	MODEL = 0
	SERIAL = 1

################################################################################
# function name: count_partitions
# receives: block device path
# does: reads /proc/partitions, counts lines matching device base name followed
#       by an integer
# returns: number of partitions on device referenced by path
################################################################################
def count_partitions(path):
	partitions = open("/proc/partitions", mode='r')
	blk_name = os.path.basename(path)
	return len(re.findall(blk_name + "\d{1,2}|" + blk_name + "p\d{1,2}",partitions.read(),re.MULTILINE))

################################################################################
# function name: only_flag_is_json
# receives: options from CLI flags
# does: returns true if --json is set by itself
# returns: returns true if --json is set by itself
################################################################################
def only_flag_is_json(options):
	if options.json:
		for key, value in options.__dict__.items():
			if value and key not in ['json', 'colour']:
				return False
		return True
	return False

################################################################################
# function name: smartctl_needed
# receives: options from CLI flags
# does: check if smartctl() needs to be called
# returns: True if smartctl() needs to be called, else False
################################################################################
def smartctl_needed(options):
	return options.outputHealth or options.outputTemp or only_flag_is_json(options)

################################################################################
# function name: smartctl
# receives: drive dictionary from build_server()
# does: executes smartctl -a --json <drive path> and saves output values
#       in drive dictionary
# returns: nothing
################################################################################
def smartctl(drive):
	global EXIT_CODE
	try:
		child = subprocess.Popen(["smartctl", "-a", drive["dev"], "--json"],
				stdout=subprocess.PIPE, universal_newlines=True)
		# child = subprocess.Popen(["cat", "/root/dev.json"],
		# 		stdout=subprocess.PIPE, universal_newlines=True)
	except OSError:
		print("Error executing smartctl. Is it installed?")
		exit(1)

	try:
		outs, errs = child.communicate(timeout=5)
	except subprocess.TimeoutExpired:
		drive["model-family"] = "smartctl timed out"
		drive["model-name"] = "smartctl timed out"
		drive["serial"] = "smartctl timed out"
		drive["capacity"] = "smartctl timed out"
		drive["firm-ver"] = "smartctl timed out"
		drive["rotation-rate"] = 1 if drive["disk_type"] == "HDD" else 0
		drive["start-stop-count"] = "smartctl timed out"
		drive["power-cycle-count"] = "smartctl timed out"
		drive["temp-c"] = "0" + u"\u2103"
		drive["current-pending-sector"] = "smartctl timed out"
		drive["start-stop-count"] = "smartctl timed out"
		drive["offline-uncorrectable"] = "smartctl timed out"
		drive["power-on-time"] = "smartctl timed out"
		drive["health"] = "POOR"
		child.kill()
		outs, errs = child.communicate()
		EXIT_CODE = 1
		return

	try:
		output = json.loads(outs)
	except json.JSONDecodeError:
		cleaned_outs = clean_json_string(outs)
		try:
			output = json.loads(cleaned_outs)
		except json.JSONDecodeError:
			print("Failed to decode JSON output from smartctl after cleaning string.")
			exit(1)

	drive["model-family"] = output["model_family"] if "model_family" in output.keys() else "?"
	drive["model-name"] = output["model_name"] if "model_name" in output.keys() else "?"
	drive["serial"] = output["serial_number"] if "serial_number" in output.keys() else "?"
	drive["capacity"] = output["user_capacity"]["bytes"] if "user_capacity" in output.keys() else "?"
	drive["firm-ver"] = output["firmware_version"] if "firmware_version" in output.keys() else "?"
	drive["rotation-rate"] = output["rotation_rate"] if "rotation_rate" in output.keys() else "?"
	if "ata_smart_attributes" in output.keys():
		for attr in output["ata_smart_attributes"]["table"]:
			if attr["name"] == "Start_Stop_Count":
				drive["start-stop-count"] = attr["raw"]["string"]
			elif attr["name"] == "Power_Cycle_Count":
				drive["power-cycle-count"] = attr["raw"]["string"]
			elif attr["name"] == "Temperature_Celsius":
				drive["temp-c"] = attr["raw"]["string"].split()[0] + u"\u2103"
			elif attr["name"] == "Current_Pending_Sector":
				drive["current-pending-sector"] = attr["raw"]["string"]
			elif attr["name"] == "Offline_Uncorrectable":
				drive["offline-uncorrectable"] = attr["raw"]["string"]
	else:
		drive["start-stop-count"] = "?"
		drive["power-cycle-count"] = "?"
		drive["temp-c"] = (str(output["temperature"]["current"]) + u"\u2103") if ("temperature" in output.keys() and "current" in output["temperature"].keys()) else "?"
		drive["current-pending-sector"] = "?"
		drive["start-stop-count"] = "?"
		drive["offline-uncorrectable"] = "?"
	drive["power-on-time"] = output["power_on_time"]["hours"] if "power_on_time" in output.keys() else "?"
	drive["health"] = "OK" if "smart_status" in output.keys() and output["smart_status"]["passed"]  else "POOR"

################################################################################
# function name: clean_json_string
# receives: input string
# does: removed characters from broken JSON string until first "{" is found
# returns: cleaned json string
# why: smartctl outputs broken JSON on some drives. Known problem drives: ST18000NM007J
################################################################################
def clean_json_string(s):
	while s and s[0] != '{':
		s = s[1:]
	return s

################################################################################
# function name: hdparm_needed
# receives: options from CLI flags
# does: check if get_model_serial() needs to be called
# returns: True if get_model_serial() needs to be called, else False
################################################################################
def hdparm_needed(options):
	return (not smartctl_needed(options)) and (options.outputModel or options.outputSerial or options.outputFirm)

################################################################################
# function name: get_model_serial_firmware
# receives: device name
# does: executes hdparm and grabs model, serial, and firm vers
# returns: tuple of model, serial, firm vers
################################################################################
def get_model_serial_firmware(device_name):
	model="unknown"
	serial="unknown"
	firmware="unknown"
	try:
		hdparm_result = subprocess.Popen(
			["hdparm","-I", device_name],
			stdout=subprocess.PIPE,
			stderr=subprocess.DEVNULL,
			universal_newlines=True
		).stdout
	except OSError:
		print("Error executing hdparm. Is it installed?")
		exit(1)
	for line in hdparm_result:
		regex_model = re.search("^\s+Model Number:\s+(.*)$",line)
		regex_serial = re.search("^\s+Serial Number:\s+(.*)$",line)
		regex_firmware = re.search("^\s+Firmware Revision:\s+(.*)$",line)
		if regex_model != None:
			model=regex_model.group(1).rstrip()
		if regex_serial != None:
			serial=regex_serial.group(1).rstrip()
		if regex_firmware != None:
			firmware=regex_firmware.group(1).rstrip()
			break
	return (model,serial,firmware)

################################################################################
# function name: check_ceph
# receives: nothing
# does: executes ceph --version
# returns: False if ceph not installed or exit code not 0, else True
################################################################################
def check_ceph():
	try:
		child = subprocess.Popen(
			["ceph","--version"],
			stdout=subprocess.PIPE,universal_newlines=True
		)
	except OSError:
		return False
	if child.wait() != 0:
		return False
	return True

################################################################################
# function name: get_osd_dict
# receives: nothing
# does: executes ceph-volume lvm list --format json, parses output into dict
#       relating device path to osd name
# returns: dictionary of dev path : osd name
################################################################################
def get_osd_dict():
	global EXIT_CODE
	osds = {}
	
	try:
		child = subprocess.Popen(
			["ceph-volume","lvm","list","--format","json"],
			stdout=subprocess.PIPE,universal_newlines=True
		)
	except OSError:
		print("Error executing ceph-volume. Is it installed?")
		exit(1)
	if child.wait() != 0:
		EXIT_CODE = child.returncode
		return
	output = json.loads(child.stdout.read())
	
	for osd in output.keys():
		dev_path = output[osd][0]["devices"][0]
		osds[dev_path] = f"osd.{osd}"
	
	return osds

################################################################################
# function name: get_capacity_dict
# receives: nothing
# does: executes lsblk, parses output into dict relating device name to capacity
# returns: dictionary of dev name : capacity in bytes
################################################################################
def get_capacity_dict():
	capacities = {}
	try:
		child = subprocess.Popen(
			["lsblk","--raw","--noheadings","--output","NAME,SIZE","--bytes","--nodeps"],
			stdout=subprocess.PIPE,universal_newlines=True
		)
	except OSError:
		print("Error executing lsblk. Is it installed?")
		exit(1)
	if child.wait() != 0:
		print("Error executing lsblk.")
		exit(child.returncode)
	for line in child.stdout:
		[name, capacity] = line.split(' ')
		capacities["/dev/" + name] = int(capacity)
	return capacities

################################################################################
# function name: build_server
# receives: CLI options
# does: checks if smartctl or get_model_serial need to be called,
#       opens vdev_id.conf, iterates through lines and creates 2D array in
#       server["rows"] containing bay dictionaries, sets values in bay
#       dictionary before inserting into array. Uses either smartctl() to fill
#       all fields of dictionary, uses dict returned by get_model_serial() to
#       model and serial fields, or uses neither depending on call_smartctl and
#       call_lsblk values.
# returns: server dictionary ({rows: [[]], meta: {}})
################################################################################
def build_server(options):
	call_smartctl = smartctl_needed(options)
	call_hdparm = hdparm_needed(options) # grab model num and serial from lslk (get_model_serial())
	capacity_lut = None
	if options.outputCapacity:
		capacity_lut = get_capacity_dict()
	# model_serial = get_model_serial() if call_lsblk else {}
	server = [[]] # server[row][bay]
		# load the existing server_info.json file in as a json object.
	json_dir = "/etc/45drives/server_info"
	server_obj = None
	if not os.path.exists(json_dir+"/server_info.json") or not os.path.isfile(json_dir+"/server_info.json"):
		print("Unable to determine server model. Run 'dmap'")
		exit(1)
	server_info_file = open(json_dir+"/server_info.json","r")
	server_obj = json.load(server_info_file)
	server_info_file.close()
	
	alias_template = {
		"H16":{
			"AV15":[23],
			"Q30":[15,23],
			"S45":[15,15,23],
			"XL60":[15,15,15,23]
		},
		"H32":{
			"Q30":[15,32],
			"S45":[15,15,32],
			"XL60":[15,15,15,32]
		},
		"STORINATOR":{
			"AV15":[15],
			"Q30":[15,15],
			"S45":[15,15,15],
			"XL60":[15,15,15,15],
			"C8":[4,4],
			"MI4":[4],
			"HL15":[15]
		},
		"STORINATORUBM":{
			"MI4_UBM":[4],
			"C8_UBM":[8]
		},
		"HOMELAB":{
      		"HL15_BEAST":[23],
			"X15":[15],
			"HL15":[15],
			"HL4":[4],
			"HL8":[4,4]
		},
  		"PROFESSIONAL":{
			"PRO15":[15],
			"PRO4":[4],
			"PRO8":[4,4]
		},
		"STORNADO":{
			"AV15":[32],
			"F32":[32]
		},
		"2USTORNADO":{
			"2U":[32]
		},
		"F2STORNADO":{
			"F2":[32],
			"F16":[16],
			"VM4":[4],
			"VM8":[8],
			"VM16":[16],
			"VM32":[32]
		},
		"AV15-BASE":{
			"AV15":[15]
		},
		"DESTROYINATOR":{
			"AV15":[15],
			"Q30":[15,15],
			"S45":[15,15,15],
			"XL60":[15,15,15,15],
			"F16":[16]
		},
		"F8":{
			"F8X1":[20],
			"F8X2":[20,20],
			"F8X3":[20,20,20],
			# "NVME-F8X1":[20],
			# "NVME-F8X2":[20,20],
			"NVME-F8X3":[20,20,20]
		},
		"SSG-6048R-E1CR24H":{
			"SSG-6048R-E1CR24H":[4,4,4,4,4,4]
		},
		"HBA16":{
			"1X":[16],
			"2X":[16,16],
			"3X":[16,16,16],
			"4X":[16,16,16,16]
		},
  		"STUDIO":{
			"STUDIO8":[4,4],
			"STUDIO15":[15]
		},
		"BYPATH": {
			"VM2":[2],
		}
	}
	CURRENT_ROW = 0
	DRIVE_COUNT = 0
	
	CONFIG_PATH = "/etc"
	try:
		vdev_id = open(CONFIG_PATH + "/vdev_id.conf", mode='r')
	except IOError:
		if "Chassis Size" in server_obj.keys() and server_obj["Chassis Size"] in ["2UGW", "2UGW-REV2"]:
			print("This program is intended to display disk information for servers with dedicated storage bays.".format(astyle=server_obj["Alias Style"]))
			print("Use command line utilities such as lsblk to see disk information for Ceph Gateways.".format(astyle=server_obj["Alias Style"]))
			exit(1)
		print("Error opening " + CONFIG_PATH + "/vdev_id.conf. Run `dmap`.")
		exit(1)

	if "Alias Style" not in server_obj.keys():
		print("Unable to determine Alias Style from /etc/45drives/server_info/server_info.json: ")
		print(json.dumps(server_obj,indent=4))
		exit(1)
	elif "Chassis Size" in server_obj.keys() and server_obj["Chassis Size"] in ["2UGW", "2UGW-REV2"]:
		print("This program is intended to display disk information for servers with dedicated storage bays.".format(astyle=server_obj["Alias Style"]))
		print("Use command line utilities such as lsblk to see disk information for Ceph Gateways.".format(astyle=server_obj["Alias Style"]))
		exit(1)
	elif server_obj["Alias Style"] not in alias_template.keys():
		print("Unknown Alias Style '{astyle}' encountered.".format(astyle=server_obj["Alias Style"]))
		exit(1)
	row_index = 1

	for line in vdev_id:
		# skip blank lines and comments
		if not line or line == "" or line[0] == '#':
			continue
		# extract bay indices and by-path path
		regex = re.search("^alias\s+(\d{1,2})-(\d{1,2})\s+(.*)$",line)
		if regex == None:
			continue
		CARD = int(regex.group(1))
		DRIVE = int(regex.group(2))
		DRIVE_COUNT += 1
		# new row
		if server_obj["Chassis Size"] not in alias_template[server_obj["Alias Style"]].keys():
			print("Unknown/Unsupported Chassis Size '{CS}' encountered.".format(CS=server_obj["Chassis Size"]))
			exit(1)
		if DRIVE_COUNT > alias_template[server_obj["Alias Style"]][server_obj["Chassis Size"]][CURRENT_ROW]:
			DRIVE_COUNT = 1
			CURRENT_ROW += 1
			server.append([])
		# process bay
		bay = {
			"dev-by-path": "",
			"bay-id": "",
			"occupied": False,
			"dev": "",
			"partitions": "",
			"model-family": "",
			"model-name": "",
			"serial": "",
			"capacity": "",
			"firm-ver": "",
			"rotation-rate": "",
			"start-stop-count": "",
			"power-cycle-count": "",
			"temp-c": "",
			"current-pending-sector": "",
			"offline-uncorrectable": "",
			"power-on-time": "",
			"health": "",
			"disk_type":""
		}
		bay["dev-by-path"] = regex.group(3) # third capture group
		bay["bay-id"] = str(CARD) + "-" + str(DRIVE)
		# if symlink exists in /dev/disk/by-path/ then bay is occupied
		if os.path.exists(bay["dev-by-path"]):
			bay["occupied"] = True
			bay["dev"] = os.path.realpath(bay["dev-by-path"])
			bay["partitions"] = count_partitions(bay["dev"])
			if options.outputType or only_flag_is_json(options):
				bay["disk_type"] = disk_type(bay["dev"])
			if call_smartctl:
				smartctl(bay) # get metadata
				if EXIT_CODE == 0:
					bay["capacity"] = format_bytes(bay["capacity"])
			elif options.outputCapacity and bay["dev"] in capacity_lut:
				bay["capacity"] = format_bytes(capacity_lut[bay["dev"]])
			if call_hdparm:
				bay["model-name"], bay["serial"], bay["firm-ver"] = get_model_serial_firmware(bay["dev"])
		# insert copy of bay
		server[CURRENT_ROW].append(bay.copy())
	vdev_id.close()
	return server, server_obj

################################################################################
# function name: get_controller
# receives: nothing
# does: executes lspci and searches for HBA conrtoller card name and
#       then calls modinfo on arg dependent on card type to get driver version
# returns: (controller name, driver version)
################################################################################
def get_controller():
	controllers = []
	driver_args = []
	driver_versions = []
	firmware_versions = []
	
	controller_names = "?"
	driver_version_names = "?"
	firmware_version_names = "-"
	
	# load the existing server_info.json file in as a json object.
	json_dir = "/etc/45drives/server_info"
	server_obj = None
	if os.path.exists(json_dir+"/server_info.json") and os.path.isfile(json_dir+"/server_info.json"):
		server_info_file = open(json_dir+"/server_info.json","r")
		server_obj = json.load(server_info_file)
		server_info_file.close()
		
		if server_obj["Alias Style"] != "AV15-BASE" and server_obj["Chassis Size"] != "MI4":
			# This is a server with HBA cards
			for hba in server_obj["HBA"]:
				if hba["Model"] not in controllers:
					controllers.append(hba["Model"])
					driver_args.append(hba["Kernel Driver"])
					firmware_versions.append(hba.get("Firmware Version","-"))
		
		elif server_obj["Alias Style"] == "AV15-BASE":
			# this is an AV15 Base Model
			controllers = ["AV15-Base"]
			driver_args = ["mpt3sas"]
			firmware_versions = ["-"]

		elif server_obj["Chassis Size"] == "MI4":
			controllers = ["Intel SATA Controller (ahci)"]
			driver_args = ["ahci"]
			firmware_versions = ["-"]
		
		#run modinfo command to obtain the drive version for each unique controller type
		for drv_arg in driver_args:
			try:
				drv_stream = subprocess.Popen(["modinfo", drv_arg], stdout=subprocess.PIPE,
						universal_newlines=True).stdout
			except OSError:
				print("Error executing modinfo.")
				exit(1)
			
			for line in drv_stream:
				regex = re.search("^version:\s+(\d.*)$",line)
				if regex != None:
					driver_versions.append(regex.group(1))
					break
		# update the controller and driver version strings with the relevant information
		if len(controllers) != 0 and len(driver_versions) != 0:
			controller_names = ", ".join(controllers)
			driver_version_names = ", ".join(driver_versions)
		
		if len(firmware_versions) != 0:
			firmware_version_names = ", ".join(firmware_versions)
	else:
		print("Unable to determine disk controller. Run 'dmap'")
		exit(1)

	return controller_names, driver_version_names, firmware_version_names

################################################################################
# function name: disk_type
# receives: block device path
# does: reads /sys/block/<device>/queue/rotational to determine if the drive is
#       a HDD or SDD
# returns: "HDD" or "SDD" depending on value read, or "-" if error
################################################################################
def disk_type(device_path):
	#get the value of "/sys/block/[device_name]/queue/rotational
	#this check will return 1 if block device is a spinner, 0 if not
	device_name = os.path.basename(device_path)
	rotational_path = "/sys/block/" + device_name + "/queue/rotational"
	if not os.path.isfile(rotational_path):
		return "-"
	rotational = open(rotational_path, mode='r')
	is_rotational = bool(int(rotational.read(1)))
	return ("HDD" if is_rotational else "SSD")

################################################################################
# function name: no_output_flags
# receives: options from parseOptions
# does: determines if no flags are passed for default output
# returns: True if default output, else False
################################################################################
def no_output_flags(options):
	return (not options.outputHealth and not options.outputModel
			and not options.outputType and not options.outputSerial
			and not options.outputTemp and not options.outputFirm
			and not options.outputOSD and not options.outputCapacity
			and not options.outputPath)

################################################################################
# function name: choose_output
# receives: bay dictionary from build_server(), output_opt, and Ceph OSD LUT
# does: creates string of info based on passed flags
# returns: string corresponding to chosen output type(s)
################################################################################
def choose_output(bay, options, osd):
	if no_output_flags(options):
		return bay["dev"] if bay["dev"] else "-"
	output = []
	if options.outputDev:
		output.append(bay["dev"].rstrip() if bay["dev"] else "-")
	if options.outputPath:
		output.append(bay["dev-by-path"].rstrip() if bay["dev-by-path"] else "-")
	if options.outputModel:
		output.append(bay["model-name"].rstrip() if bay["model-name"] else "-")
	if options.outputSerial:
		output.append(bay["serial"].rstrip() if bay["serial"] else "-")
	if options.outputFirm:
		output.append(bay["firm-ver"].rstrip() if bay["firm-ver"] else "-")
	if options.outputType:
		output.append(disk_type(bay["dev"]))
	if options.outputHealth:
		output.append(bay["health"].rstrip() if bay["health"] else "-")
	if options.outputTemp:
		output.append(bay["temp-c"].rstrip() if bay["temp-c"] else "-")
	if options.outputOSD:
		output.append(osd[bay["dev"]] if bay["dev"] in osd.keys() else "-")
	if options.outputCapacity:
		output.append(bay["capacity"].rstrip() if bay["capacity"] else "-")
	return ",".join(output)

################################################################################
# function name: choose_output_header
# receives: options from CLI opt parser
# does: creates header for column based on output options passed
# returns: header string corresponding to chosen output type
################################################################################
def choose_output_header(options):
	if no_output_flags(options):
		return "Dev"
	output = []
	if options.outputDev:
		output.append("Dev")
	if options.outputPath:
		output.append("Path")
	if options.outputModel:
		output.append("Model")
	if options.outputSerial:
		output.append("Serial")
	if options.outputFirm:
		output.append("Firmware")
	if options.outputType:
		output.append("Type")
	if options.outputHealth:
		output.append("Health")
	if options.outputTemp:
		output.append("Temp C")
	if options.outputOSD:
		output.append("OSD")
	if options.outputCapacity:
		output.append("Capacity")
	return ",".join(output)

################################################################################
# function name: print_bays
# receives: server dictionary ({rows: [], meta: {}}), options, Ceph OSD LUT
# does: prints table representing server with output based on
#       options and choose_output() call
# returns: nothing
################################################################################
def print_bays(server, options, osd, server_info):
	print_array = []
	
	for row in reversed(server["rows"]):
		print_array.append([])
		for bay in row:
			cell_string = "{txt:<4} (".format(txt=bay["bay-id"]) + choose_output(bay, options, osd) + ")"
			colour = ""
			if options.colour:
				if bay["occupied"] == False:
					colour = "GREY"
				elif bay["partitions"] == 0:
					colour = "GREEN"
				elif bay["partitions"] > 0:
					colour = "LGREEN"
			else:
				if bay["occupied"] and bay["partitions"] == 0:
					cell_string = " * " + cell_string
				elif bay["occupied"] and bay["partitions"] > 0:
					cell_string = "** " + cell_string
				else:
					cell_string = "   " + cell_string
			print_array[-1].append((cell_string, colour))
   
	# # Normalize column heights so ragged rows show fully
	# max_height = max((len(col) for col in print_array), default=0)
	# for col in print_array:
	# 	while len(col) < max_height:
	# 		# blank cell to keep columns aligned for rows with fewer bays
	# 		col.append(("", ""))
	
	header_text = [
		"Storage Disk Info"
	]
	if server["meta"]["disk-controller"] not in ["?","-"]:
		header_text.append("Disk Controller(s): " + server["meta"]["disk-controller"])
	if server["meta"]["driver-version"] not in ["?","-"]:
		header_text.append("Driver Version(s): " + server["meta"]["driver-version"])
	if server["meta"]["firmware-version"] not in ["?","-"]:
		header_text.append("Firmware Version(s): " + server["meta"]["firmware-version"])
	
	col_headers = []
	for row in reversed(range(len(server["rows"]))):
		h_txt = ("Row " + str(row + 1) + " ID (" + choose_output_header(options)) + ")"
		col_headers.append(h_txt)
	
	table_print(ansi = options.colour, c_count = len(print_array), c_txt = print_array, c_labels = col_headers, h_txt = header_text, padding = 1)
	
	# legend
	if "Alias Style" in server_info.keys() and server_info["Alias Style"] not in ["2USTORNADO","F2STORNADO"]:
		print(" <-- motherboard | front plate -->")
	print(" Legend:")
	if options.colour:
		print(ANSI_colours.GREY + " Empty " +
				ANSI_colours.GREEN + " Occupied (no partitions) " +
				ANSI_colours.LGREEN + " Occupied (1 or more partitions)" +
				ANSI_colours.END)
	else:
		print("    Empty " + "  * Occupied (no partitions) " + " ** Occupied (1 or more partitions)")

################################################################################
# function name: print_bays_front
# receives: server dictionary ({rows: [], meta: {}}), options, Ceph OSD LUT
# does: prints table representing server with output based on
#       options and choose_output() call, with rows going across
#       for use with MI4 and C8 chassis
# returns: nothing
################################################################################
def print_bays_front(server, options, osd, server_info):
	print_array = []
	
	for i in range(len(server["rows"][0])):
		print_array.append([None]*len(server["rows"]))
		for j in reversed(range(len(server["rows"]))):
			bay = server["rows"][j][i]
			cell_string = "{txt:<4} (".format(txt=bay["bay-id"]) + choose_output(bay, options, osd) + ")"
			colour = ""
			if options.colour:
				if bay["occupied"] == False:
					colour = "GREY"
				elif bay["partitions"] == 0:
					colour = "GREEN"
				elif bay["partitions"] > 0:
					colour = "LGREEN"
			else:
				if bay["occupied"] and bay["partitions"] == 0:
					cell_string = " * " + cell_string
				elif bay["occupied"] and bay["partitions"] > 0:
					cell_string = "** " + cell_string
				else:
					cell_string = "   " + cell_string
			print_array[i][j] = (cell_string, colour)
	
	header_text = [
		"Storinator Disk Info",
		"Disk Controller(s): " + server["meta"]["disk-controller"],
		"Driver version(s): " + server["meta"]["driver-version"],
		"Firmware Version(s): " + server["meta"]["firmware-version"]
	]
	
	col_headers = []
	for row in range(len(server["rows"][0])):
		h_txt = (" ID (" + choose_output_header(options)) + ")"
		col_headers.append(h_txt)
	
	table_print(ansi = options.colour, c_count = len(print_array), c_txt = print_array, c_labels = col_headers, h_txt = header_text, padding = 1)
	
	# legend
	print(" Legend:")
	if options.colour:
		print(ANSI_colours.GREY + " Empty " +
				ANSI_colours.GREEN + " Occupied (no partitions) " +
				ANSI_colours.LGREEN + " Occupied (1 or more partitions)" +
				ANSI_colours.END)
	else:
		print("    Empty " + "  * Occupied (no partitions) " + " ** Occupied (1 or more partitions)")

################################################################################
# function name: format_bytes
# receives: integer value in bytes
# does: formats size_bytes in SI base units
# returns: string containing formatted size_bytes value
################################################################################
def format_bytes(size_bytes):
	if size_bytes == 0 or isinstance(size_bytes,str):
		return "0 B"
	size_name = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
	i = int(math.floor(math.log(size_bytes, 1024)))
	p = math.pow(1024, i)
	s = round(size_bytes / p, 2)
	return "%s %s" % (s, size_name[i])

################################################################################
# function name: root_check
# indicates if the script is being run with root privelages.
################################################################################
def root_check():
	root_test =	subprocess.run(["ls","/root"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode
	return root_test == 0

################################################################################
# function name: main
# receives: nothing
# does: parses CLI options, initializes server dictionary
#       ({rows: [[]], meta: {}}), retrieves controller name and driver version
#       from get_controller(), if-else to decide what data to print
# returns: 0 (success)
################################################################################
def main():
	parser = OptionParser() #use optparse to handle command line arguments
	parser.add_option("-j", "--json", action="store_true",
			dest="json", default=False, help="Output in JSON format")
	parser.add_option("-n", "--no-color", "--no-colour", action="store_false",
			dest="colour", default=True, help="Replace colour coding with asterisks")
	parser.add_option("-d", "--device", action="store_true", dest="outputDev",
			default=False, help="Output device name \"/dev/sd<x>/\"")
	parser.add_option("-H", "--health", action="store_true", dest="outputHealth",
			default=False, help="Output SMARTCTL health (slow)")
	parser.add_option("-m", "--model", action="store_true", dest="outputModel",
			default=False, help="Output model names")
	parser.add_option("-t", "--type", action="store_true", dest="outputType",
			default=False, help="Output drive types (HDD/SSD)")
	parser.add_option("-s", "--serial", action="store_true", dest="outputSerial",
			default=False, help="Output serial numbers")
	parser.add_option("-T", "--temp", action="store_true", dest="outputTemp",
			default=False, help="Output temperature (deg-C) (slow)")
	parser.add_option("-f", "--firmware", action="store_true", dest="outputFirm",
			default=False, help="Output firmware version")
	parser.add_option("-o", "--ceph-osd", action="store_true", dest="outputOSD",
			default=False, help="Output OSD name - Ceph only")
	parser.add_option("-c", "--capacity", action="store_true", dest="outputCapacity",
			default=False, help="Output device capacity")
	parser.add_option("-p", "--path", action="store_true", dest="outputPath",
			default=False, help="Output device path")
	(options, args) = parser.parse_args()

	start_time = time.time()

	if smartctl_needed(options) and not root_check():
		print("options requested require use of smartctl. smartctl must be run as root. \nRun lsdev as root to get specified options.")
		os.sys.exit(1)

	(rows, server_info) = build_server(options)

	server = {
		"rows": rows,
		"meta": {
			"disk-controller": "",
			"driver-version": "",
			"firmware-version":""
		},
		"lsdevDuration": 0 
	}
	
	#get Ceph OSD LUT
	osd = {}
	if options.outputOSD:
		if check_ceph():
			osd = get_osd_dict()
		else:
			print("-o --ceph-osd can only be used with Ceph.")
	
	#get server information
	server["meta"]["disk-controller"], server["meta"]["driver-version"], server["meta"]["firmware-version"] = get_controller()
	
	#print relevant information based on command line arguments
	if(options.json):
		end_time = time.time()
		server["lsdevDuration"] = (end_time - start_time)
		print(json.dumps(server, indent = 4))
	elif(
		server_info["Alias Style"] == "STORINATOR" and 
		(server_info["Chassis Size"] == "C8" or server_info["Chassis Size"] == "MI4")):
		print_bays_front(server, options, osd, server_info)
	elif(server_info["Alias Style"] == "SSG-6048R-E1CR24H" and server_info["Chassis Size"] == "SSG-6048R-E1CR24H"):
		print_bays_front(server, options, osd, server_info)
	else:
		print_bays(server, options, osd, server_info)
	exit(0)

if __name__ == "__main__":
	main()
