#!/usr/bin/python # -*- coding: utf-8 -*- #--------------------------------------------------------------------- # # Copyright © 2011 Canonical Ltd. # # Author: James Hunt # # 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=\" %s | { start on | 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()