#!/usr/bin/python
# -*- coding: utf-8 -*-
#---------------------------------------------------------------------
#
# Copyright © 2011 Canonical Ltd.
#
# Author: James Hunt <james.hunt@canonical.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#---------------------------------------------------------------------

#---------------------------------------------------------------------
# Script to take output of "initctl show-config -e" and convert it into
# a Graphviz DOT language (".dot") file for procesing with dot(1), etc.
#
# Notes:
#
# - Slightly laborious logic used to satisfy graphviz requirement that
#   all nodes be defined before being referenced.
#
# Usage:
#
#   initctl show-config -e > initctl.out
#   initctl2dot -f initctl.out -o upstart.dot
#   dot -Tpng -o upstart.png upstart.dot
#
# Or more simply:
#
#  initctl2dot -o - | dot -Tpng -o upstart.png
#
# See also:
#
# - dot(1).
# - initctl(8).
# - http://www.graphviz.org.
#---------------------------------------------------------------------

import sys
import re
import fnmatch
import os
from string import split
import datetime
from subprocess import (Popen, PIPE)
from optparse import OptionParser

jobs   = {}
events = {}
cmd = "initctl --system show-config -e"
script_name =  os.path.basename(sys.argv[0])

job_events = [ 'starting', 'started', 'stopping', 'stopped' ]

# list of jobs to restict output to
restrictions_list = []

default_color_emits    = 'green'
default_color_start_on = 'blue'
default_color_stop_on  = 'red'
default_color_event    = 'thistle'
default_color_job      = '#DCDCDC' # "Gainsboro"
default_color_text     = 'black'
default_color_bg       = 'white'

default_outfile        = 'upstart.dot'


def header(ofh):
  global options

  str  = "digraph upstart {\n"

  # make the default node an event to simplify glob code
  str += "  node [shape=\"diamond\", fontcolor=\"%s\", fillcolor=\"%s\", style=\"filled\"];\n" \
    % (options.color_event_text, options.color_event)
  str += "  rankdir=LR;\n"
  str += "  overlap=false;\n"
  str += "  bgcolor=\"%s\";\n" % options.color_bg
  str += "  fontcolor=\"%s\";\n" % options.color_text

  ofh.write(str)


def footer(ofh):
  global options

  epilog = "overlap=false;\n"
  epilog += "label=\"Generated on %s by %s\\n" % \
    (str(datetime.datetime.now()), script_name)

  if options.restrictions:
    epilog += "(subset, "
  else:
    epilog += "("

  if options.infile:
    epilog += "from file data).\\n"
  else:
    epilog += "from '%s' on host %s).\\n" % \
      (cmd, os.uname()[1])

  epilog += "Boxes of color %s denote jobs.\\n" % options.color_job
  epilog += "Solid diamonds of color %s denote events.\\n" % options.color_event
  epilog += "Dotted diamonds denote 'glob' events.\\n"
  epilog += "Emits denoted by %s lines.\\n" % options.color_emits
  epilog += "Start on denoted by %s lines.\\n" % options.color_start_on
  epilog += "Stop on denoted by %s lines.\\n" % options.color_stop_on
  epilog += "\";\n"
  epilog += "}\n"
  ofh.write(epilog)


# Map dash to underscore since graphviz node names cannot
# contain dashes. Also remove dollars and colons
def sanitise(s):
  return s.replace('-', '_').replace('$', 'dollar_').replace('[', \
  'lbracket').replace(']', 'rbracket').replace('!', \
  'bang').replace(':', '_').replace('*', 'star').replace('?', 'question')


# Convert a dollar in @name to a unique-ish new name, based on @job and
# return it. Used for very rudimentary instance handling.
def encode_dollar(job, name):
  if name[0] == '$':
    name = job + ':' + name
  return name


def mk_node_name(name):
  return sanitise(name)


# Jobs and events can have identical names, so prefix them to namespace
# them off.
def mk_job_node_name(name):
  return mk_node_name('job_' + name)


def mk_event_node_name(name):
  return mk_node_name('event_' + name)


def show_event(ofh, name):
    global options
    str = "%s [label=\"%s\", shape=diamond, fontcolor=\"%s\", fillcolor=\"%s\"," % \
      (mk_event_node_name(name), name, options.color_event_text, options.color_event)

    if '*' in name:
      str += " style=\"dotted\""
    else:
      str += " style=\"filled\""

    str += "];\n"

    ofh.write(str)

def show_events(ofh):
  global events
  global options
  global restrictions_list

  events_to_show = []

  if restrictions_list:
    for job in restrictions_list:

      # We want all events emitted by the jobs in the restrictions_list.
      events_to_show += jobs[job]['emits']

      # We also want all events that jobs in restrictions_list start/stop
      # on.
      events_to_show += jobs[job]['start on']['event']
      events_to_show += jobs[job]['stop on']['event']

      # We also want all events emitted by all jobs that jobs in the
      # restrictions_list start/stop on. Finally, we want all events
      # emmitted by those jobs in the restrictions_list that we
      # start/stop on.
      for j in jobs[job]['start on']['job']:
        if jobs.has_key(j) and jobs[j].has_key('emits'):
          events_to_show += jobs[j]['emits']

      for j in jobs[job]['stop on']['job']:
        if jobs.has_key(j) and jobs[j].has_key('emits'):
          events_to_show += jobs[j]['emits']
  else:
    events_to_show = events

  for e in events_to_show:
    show_event(ofh, e)


def show_job(ofh, name):
  global options

  ofh.write("""
    %s [shape=\"record\", label=\"<job> %s | { <start> start on | <stop> stop on }\", fontcolor=\"%s\", style=\"filled\", fillcolor=\"%s\"];
    """ % (mk_job_node_name(name), name, options.color_job_text, options.color_job))


def show_jobs(ofh):
  global jobs
  global options
  global restrictions_list

  if restrictions_list:
    jobs_to_show = restrictions_list
  else:
    jobs_to_show = jobs

  for j in jobs_to_show:
    show_job(ofh, j)
    # add those jobs which are referenced by existing jobs, but which
    # might not be available as .conf files. For example, plymouth.conf
    # references gdm *or* kdm, but you are unlikely to have both
    # installed.
    for s in jobs[j]['start on']['job']:
      if s not in jobs_to_show:
        show_job(ofh, s)

    for s in jobs[j]['stop on']['job']:
      if s not in jobs_to_show:
        show_job(ofh, s)

  if not restrictions_list:
    return

  # Having displayed the jobs in restrictions_list,
  # we now need to display all jobs that *those* jobs
  # start on/stop on.
  for j in restrictions_list:
    for job in jobs[j]['start on']['job']:
      show_job(ofh, job)
    for job in jobs[j]['stop on']['job']:
      show_job(ofh, job)

  # Finally, show all jobs which emit events that jobs in the
  # restrictions_list care about.
  for j in restrictions_list:

    for e in jobs[j]['start on']['event']:
      for k in jobs:
        if e in jobs[k]['emits']:
          show_job(ofh, k)

    for e in jobs[j]['stop on']['event']:
      for k in jobs:
        if e in jobs[k]['emits']:
          show_job(ofh, k)


def show_edge(ofh, from_node, to_node, color):
  ofh.write("%s -> %s [color=\"%s\"];\n" % (from_node, to_node, color))


def show_start_on_job_edge(ofh, from_job, to_job):
  global options
  show_edge(ofh, "%s:start" % mk_job_node_name(from_job),
    "%s:job" % mk_job_node_name(to_job), options.color_start_on)


def show_start_on_event_edge(ofh, from_job, to_event):
  global options
  show_edge(ofh, "%s:start" % mk_job_node_name(from_job),
    mk_event_node_name(to_event), options.color_start_on)


def show_stop_on_job_edge(ofh, from_job, to_job):
  global options
  show_edge(ofh, "%s:stop" % mk_job_node_name(from_job),
    "%s:job" % mk_job_node_name(to_job), options.color_stop_on)


def show_stop_on_event_edge(ofh, from_job, to_event):
  global options
  show_edge(ofh, "%s:stop" % mk_job_node_name(from_job),
    mk_event_node_name(to_event), options.color_stop_on)


def show_job_emits_edge(ofh, from_job, to_event):
  global options
  show_edge(ofh, "%s:job" % mk_job_node_name(from_job),
    mk_event_node_name(to_event), options.color_emits)


def show_edges(ofh):
  global events
  global jobs
  global options
  global restrictions_list

  glob_jobs = {}

  if restrictions_list:
    jobs_list = restrictions_list
  else:
    jobs_list = jobs

  for job in jobs_list:

    for s in jobs[job]['start on']['job']:
      show_start_on_job_edge(ofh, job, s)

    for s in jobs[job]['start on']['event']:
      show_start_on_event_edge(ofh, job, s)

    for s in jobs[job]['stop on']['job']:
      show_stop_on_job_edge(ofh, job, s)

    for s in jobs[job]['stop on']['event']:
      show_stop_on_event_edge(ofh, job, s)

    for e in jobs[job]['emits']:
      if '*' in e:
        # handle glob patterns in 'emits'
        glob_events = []
        for _e in events:
          if e != _e and fnmatch.fnmatch(_e, e):
            glob_events.append(_e)
        glob_jobs[job] = glob_events

      show_job_emits_edge(ofh, job, e)

    if not restrictions_list:
      continue

    # Add links to events emitted by all jobs which current job
    # start/stops on
    for j in jobs[job]['start on']['job']:
      if not jobs.has_key(j):
        continue
      for e in jobs[j]['emits']:
        show_job_emits_edge(ofh, j, e)

    for j in jobs[job]['stop on']['job']:
      for e in jobs[j]['emits']:
        show_job_emits_edge(ofh, j, e)

  # Create links from jobs (which advertise they emits a class of
  # events, via the glob syntax) to all the events they create.
  for g in glob_jobs:
    for ge in glob_jobs[g]:
      show_job_emits_edge(ofh, g, ge)

  if not restrictions_list:
    return

  # Add jobs->event links to jobs which emit events that current job
  # start/stops on.
  for j in restrictions_list:

    for e in jobs[j]['start on']['event']:
      for k in jobs:
        if e in jobs[k]['emits'] and e not in restrictions_list:
          show_job_emits_edge(ofh, k, e)

    for e in jobs[j]['stop on']['event']:
      for k in jobs:
        if e in jobs[k]['emits'] and e not in restrictions_list:
          show_job_emits_edge(ofh, k, e)


def read_data():
  global jobs
  global events
  global options
  global cmd
  global job_events

  if options.infile:
    try:
      ifh = open(options.infile, 'r')
    except:
      sys.exit("ERROR: cannot read file '%s'" % options.infile)
  else:
    try:
      ifh = Popen(split(cmd), stdout=PIPE).stdout
    except:
      sys.exit("ERROR: cannot run '%s'" % cmd)

  for line in ifh.readlines():
      record = {}
      line = line.rstrip()

      result = re.match('^\s+start on ([^,]+) \(job:\s*([^,]*), env:', line)
      if result:
        _event = encode_dollar(job, result.group(1))
        _job   = result.group(2)
        if _job:
          jobs[job]['start on']['job'][_job] = 1
        else:
          jobs[job]['start on']['event'][_event] = 1
          events[_event] = 1
        continue

      result = re.match('^\s+stop on ([^,]+) \(job:\s*([^,]*), env:', line)
      if result:
        _event = encode_dollar(job, result.group(1))
        _job   = result.group(2)
        if _job:
          jobs[job]['stop on']['job'][_job] = 1
        else:
          jobs[job]['stop on']['event'][_event] = 1
          events[_event] = 1
        continue

      if re.match('^\s+emits', line):
        event = (line.lstrip().split())[1]
        event = encode_dollar(job, event)
        events[event] = 1
        jobs[job]['emits'][event] = 1
      else:
        tokens = (line.lstrip().split())

        if len(tokens) != 1:
          sys.exit("ERROR: invalid line: %s" % line.lstrip())

        job_record      = {}

        start_on        = {}
        start_on_jobs   = {}
        start_on_events = {}

        stop_on         = {}
        stop_on_jobs    = {}
        stop_on_events  = {}

        emits           = {}

        start_on['job']    = start_on_jobs
        start_on['event']  = start_on_events

        stop_on['job']     = stop_on_jobs
        stop_on['event']   = stop_on_events

        job_record['start on'] = start_on
        job_record['stop on']  = stop_on
        job_record['emits']    = emits

        job = (tokens)[0]
        jobs[job] = job_record


def main():
  global jobs
  global options
  global cmd
  global default_color_emits
  global default_color_start_on
  global default_color_stop_on
  global default_color_event
  global default_color_job
  global default_color_text
  global default_color_bg
  global restrictions_list

  description = "Convert initctl(8) output to GraphViz dot(1) format."
  epilog = \
    "See http://www.graphviz.org/doc/info/colors.html for available colours."

  parser = OptionParser(description=description, epilog=epilog)

  parser.add_option("-r", "--restrict-to-jobs",
      dest="restrictions",
      help="Limit display of 'start on' and 'stop on' conditions to " +
      "specified jobs (comma-separated list).")

  parser.add_option("-f", "--infile",
      dest="infile",
      help="File to read '%s' output from. If not specified, " \
      "initctl will be run automatically." % cmd)

  parser.add_option("-o", "--outfile",
      dest="outfile",
      help="File to write output to (default=%s)" % default_outfile)

  parser.add_option("--color-emits",
      dest="color_emits",
      help="Specify color for 'emits' lines (default=%s)." %
      default_color_emits)

  parser.add_option("--color-start-on",
      dest="color_start_on",
      help="Specify color for 'start on' lines (default=%s)." %
      default_color_start_on)

  parser.add_option("--color-stop-on",
      dest="color_stop_on",
      help="Specify color for 'stop on' lines (default=%s)." %
      default_color_stop_on)

  parser.add_option("--color-event",
      dest="color_event",
      help="Specify color for event boxes (default=%s)." %
      default_color_event)

  parser.add_option("--color-text",
      dest="color_text",
      help="Specify color for summary text (default=%s)." %
      default_color_text)

  parser.add_option("--color-bg",
      dest="color_bg",
      help="Specify background color for diagram (default=%s)." %
      default_color_bg)

  parser.add_option("--color-event-text",
      dest="color_event_text",
      help="Specify color for text in event boxes (default=%s)." %
      default_color_text)

  parser.add_option("--color-job-text",
      dest="color_job_text",
      help="Specify color for text in job boxes (default=%s)." %
      default_color_text)

  parser.add_option("--color-job",
      dest="color_job",
      help="Specify color for job boxes (default=%s)." %
      default_color_job)

  parser.set_defaults(color_emits=default_color_emits,
  color_start_on=default_color_start_on,
  color_stop_on=default_color_stop_on,
  color_event=default_color_event,
  color_job=default_color_job,
  color_job_text=default_color_text,
  color_event_text=default_color_text,
  color_text=default_color_text,
  color_bg=default_color_bg,
  outfile=default_outfile)

  (options, args) = parser.parse_args()

  if options.outfile == '-':
    ofh = sys.stdout
  else:
    try:
      ofh = open(options.outfile, "w")
    except:
      sys.exit("ERROR: cannot open file %s for writing" % options.outfile)

  if options.restrictions:
    restrictions_list = options.restrictions.split(",")

  read_data()

  for job in restrictions_list:
    if not job in jobs:
      sys.exit("ERROR: unknown job %s" % job)

  header(ofh)
  show_events(ofh)
  show_jobs(ofh)
  show_edges(ofh)
  footer(ofh)


if __name__ == "__main__":
  main()
