#!/usr/bin/python

# This tool analyzes the ps output and groups it in a more easily readable way.
# The group matching is defined by this table:
#
# Note: if tweaking this table, you may want to use the unit-test sources
# ("ps -aux" outputs) at http://qalogserver/qa/log/bug_logs/73434/ps_sources
groupings = [
    { 'name':'idle',
      'pat':'\[idle: .*' },

    { 'name':'kernel threads',
      'subs':
      [ { 'name':'coalescer',	'pat':'\[kt:.*coal.*' },
        { 'name':'defer queue',	'pat':'\[kt:.*dfq.*' },
        { 'name':'bsw offload',	'pat':'\[kt:.*bsw_offload.*' },
        { 'name':'dwt', 	'pat':'\[kt:.*dwt.*' },
        { 'name':'other threads', 'pat':'\[kt: ' },
        ] },

    { 'name':'client servicing',
      'subs':
      [ { 'name':'nfs', 	'pat':'nfsd: .*' },
        { 'name':'smb', 	'pat':'.*/smbd.*' },
        { 'name':'http',	'pat':'.*/httpd.*' },
        { 'name':'iscsi',	'pat':'.*/isi_iscsi_d.*' },
        { 'name':'ftp', 	'pat':'.*/(ftpd|vsftpd).*' },
        ] },

    { 'name':'network',
      'subs':
      [ { 'name':'swi-net',	'pat':'\[swi[0-9]+: net\]' },
        { 'name':'net interrupts',
          #include any irq's that are *only* em-related
          'pat':'(\[(em[0-9]+ taskq|irq[0-9]+: em[0-9]+)\])' },
        { 'name':'net management',
          'pat':'.*/(isi_(flexnet_d|dnsiq_d|webui_d|alert)|snmpd).*' },
        ] },

    { 'name':'job services',
      'subs':
      [ { 'name':'job engine',	'pat':'.*isi_(restripe_d|job_d).*' },
        { 'name':'SyncIQ',	'pat':'.*isi_migr_.*' },
        ] },

    { 'name':'disk',
      'subs':
      [ { 'name':'disk subsystem',
          #include any irq's that are *only* disk-related
          'pat':'(\[g_(up|down|event)\]|\[xpt_.*|.*: cambio\]|'+
          '.*: mpt_soft\]|\[irq[0-9]+: (ata|twe|mp[st])[0-9]\])'},
        { 'name':'driveD',	'pat':'.*/(isi_drive_d|isi_drivemon).*' },
        ] },

    { 'name':'IB backend',
      'subs':
      [ { 'name':'backend traffic',
          #include the mthca and mlx irqs regardless of whether they're shared
          'pat':'\[(irq[0-9]+: .*mthca[0-9]+.*|irq[0-9]+: .*mlx4_core[0-9]+.*|mthca.*|mlx.*|ib_worker.*|sdp.*)' },
        { 'name':'backend management',
          'pat':'(opensm:.*|isi_eth_mixer_d.*|ifsd:.*)' },
        ] },

    { 'name':'interrupts',
      'pat':'\[(irq|swi).*' },

    ## We must end in a "catch-all" pattern, for sanity
    { 'name':'other',
      'pat':'.*' },
    ]



import getopt
import re
import sys

EXEC_NAME = "isi_ps_group"

def error_exit(str, exitval=1):
    print >> sys.stderr, str
    if exitval:
        print >> sys.stderr, "\nSee %s -h for more info" % EXEC_NAME
    sys.exit(exitval)


gopts = {}

# parse command-line options into gopts
def parse_options():
    try:
        opts, args = getopt.getopt(
            sys.argv[1:], 'cfhpxv', 
            ['config', 'file', 'help', 'procs', 'expand', 'verbose'])

        for o, a in opts:
            if o in ['-c', '--config']:
                gopts['show_config'] = True
            elif o in ['-f', '--file']:
                gopts['from_file'] = True
            elif o in ['-h', '--help']:
                gopts['help'] = True
            elif o in ['-p', '--procs']:
                # I'm too lazy to get procs working without expand, so
                # force expand too.
                gopts['procs'] = True
                gopts['expand'] = True
            elif o in ['-x', '--expand']:
                gopts['expand'] = True
            elif o in ['-v', '--verbose']:
                gopts['verbose'] = True
            else:
                error_exit("OPTS_TABLE_ERROR: arg %s(%s) not matched" % (o,a))

    except getopt.GetoptError, e:
        error_exit("ERROR parsing: %s" % str(e.msg))

    if args:
        if not gopts.has_key('from_file'):
            error_exit("ERROR parsing: unexpected args %s (or filename without -f)" % args)
        gopts['files'] = args

    if gopts.has_key('help'):
        error_exit(
"""%s [-x] [-p] [-f file]
 -x       expand major groupings into sub-groupings
 -p       show specific processes counted for each entry

Debug flags:
 -c       show configuration and exit
 -f file  parse from file instead of raw ps output.
          File is expected to be "ps -auxwww" output
 -v       verbose debugging output""" % EXEC_NAME, 
        exitval=0)


# Extract ps output direct from 'ps' command.
def get_pcpu_pairs_from_system():
    ## lame, but import here so that this can be used with -f on non-ifs machines
    import isi.app.lib.procs as procs
    # Make ps do the work of selecting the columns and removing the headers for us
    cmd = "ps -ax -opcpu= -ocommand="
    (error, output) = procs.get_cmd_output(cmd)
    if error:
        error_exit("ERROR unable to get ps output (%s)" % cmd)
    return output

# Extract ps output from a file saved as "ps -aux" > /tmp/file
# (or maybe also some similar versions, though likely not... this isn't robust)
def get_pcpu_pairs_from_file(filename):
    f = open(filename)
    lines = f.readlines()
    output = []
    found = False
    for line in lines:
        line = line.strip()

        # remove any leading isi_for_array node-stamp, if one exists
        # defined as <name>-<number>:
        line = re.sub("^\S+-[0-9]+: +", "", line)

        # throw out a header line if it exists (before any valid lines)
        if not found and re.match("USER\s+", line):
            if gopts.has_key('verbose'):
                print >> sys.stderr, "ignoring header line %s" % line
            continue

        scan = re.split('\s*', line, maxsplit=10)

        if len(scan) < 11:
            if found or gopts.has_key('verbose'):
                print >> sys.stderr, "ignoring line %s" % line
        else:
            found = True
            output.append( " %s %s" % ( scan[2] , scan[10] ) )
    return output

# Convert the given line-strings into a list-of-dictionaries
# of (float)pcpu and (string)command.
def get_pcpu_from_pairs(lines):
    cmds = []
    mc = re.compile(" *(?P<pcpu>[0-9\\.]+) (?P<command>.*)")
    for line in lines:
	m = mc.match(line)
        if m:
            md = m.groupdict()
            md['pcpu'] = float(md['pcpu'])
            cmds.append(md)
            
    return cmds

def flatten_patterns(groups, require_pat=True):
    flat = []
    for elm in groups:
        if not require_pat or elm.has_key('pat'):
            flat.append(elm)
        if elm.has_key('subs'):
            flat.extend(elm['subs'])
    return flat

# Process each command into the appropriate aggregate
def create_aggregate(aggs, cmds):
    # get a flat table of all patterns
    flat = flatten_patterns(aggs)

    # initialize our groupings table
    for opts in flat:
        opts['pcpu'] = 0.0
        opts['re'] = re.compile(opts['pat'])
        opts['matched'] = []

    # sort each command into the appropriate group
    for cmd in cmds:
        for opts in flat:
            if opts['re'].match(cmd['command']):
                opts['pcpu'] += cmd['pcpu']
                opts['matched'].append(cmd)
                break
        else:
            error_exit("MATCH_TABLE_ERROR: failed any match for %s!" % cmd)

    return aggs

# Calculate the summed pcpus for all the aggregates with subs
def sum_aggregates(aggs):
    for agg in aggs:
        if agg.has_key('subs'):
            agg['pcpu'] = 0.0
            for sub in agg['subs']:
                agg['pcpu'] += sub['pcpu']

# Normalize the results to 100% (removes both cpu-count confusion as well as
# doesn't-add-to-100% confusion)
def normalize_aggregates(aggs):
    # sum up all the raw pcpus seen (don't include summed aggregates)
    totcpu = 0.0
    for agg in flatten_patterns(aggs):
        totcpu += agg['pcpu']
    # now scale all the pcpus, including those of summed aggregates
    for agg in flatten_patterns(aggs, require_pat=False):
        agg['norm_cpu'] = 100.0 * agg['pcpu'] / totcpu

# Sort the results from highest CPU to least
# And also sort the subs within each grouping
def sort_aggregates(aggs):
    # switch b,a to do a reverse sort...
    def sort_fun(b, a):
        return cmp(a['pcpu'], b['pcpu'])
    aggs.sort(sort_fun)
    for agg in aggs:
        if agg.has_key('subs'):
            agg['subs'].sort(sort_fun)

# The main function -- grab and show the grouped ps output
def ps_grouped():
    def show_one(agg, indent=""):
        print "%s %5.1f  %s" % ( indent, agg['norm_cpu'], agg['name'] )
        if gopts.has_key('procs') and agg.has_key('matched'):
            for proc in agg['matched']:
                print "%s      %5.1f  %s" % (
                    indent, proc['pcpu'], proc['command'])

    if gopts.has_key('from_file'):
        lines = []
        for file in gopts['files']:
            newlines = get_pcpu_pairs_from_file(file)
            lines.extend(newlines)
    else:
        lines = get_pcpu_pairs_from_system()
    pcpus = get_pcpu_from_pairs(lines)
    aggs = create_aggregate(groupings, pcpus)

    sum_aggregates(aggs)
    normalize_aggregates(aggs)
    sort_aggregates(aggs)

    for agg in aggs:
        show_one(agg)
        if agg.has_key('subs') and gopts.has_key('expand'):
            for sub in agg['subs']:
                show_one(sub, indent="  ")

# For debugging sanity -- show the configuration being used
def show_config():
    def show_one_config(agg, indent=""):
        name = "%s%s" % (indent, agg['name'])
        details = ""
        if agg.has_key('pat'):
            details += " %s" % agg['pat']
        print "  %-22s%s" % (name, details)

    show_one_config({'name':'Grouping', 'pat':'Pattern'})
    show_one_config({'name':'-'*16, 'pat':'-'*16})
    for agg in groupings:
        show_one_config(agg)
        if agg.has_key('subs'):
            for sub in agg['subs']:
                show_one_config(sub, indent="  ")

def main():
    parse_options()

    if gopts.has_key('show_config'):
        show_config()
        sys.exit(0)

    ps_grouped()


if __name__ == '__main__':
    main()
