#!/usr/bin/perl # # Danga's Statistics Gatherer # Gathers statistics using plugins. # # Command line options: # # --conf STRING set what config file to use for options # --server STRING set what server to point at # --port INT the port of the server to use # --foreground if present, tell the server to force to the foreground # # Configuration file format: # # server = STRING location of statistics server # port = INT port number server listens on # # job: plugin(parameter, parameter) # job2: plugin # helperplugin(parameter, parameter) # ... # # Copyright 2004, Danga Interactive # # Authors: # Mark Smith # # License: # undecided. # use strict; use lib "$ENV{LJHOME}/cgi-bin"; use Getopt::Long; use POSIX qw(:sys_wait_h); use Danga::Daemon; # config my $path = "$ENV{LJHOME}/bin/spud"; my $plugins_path = "$path/plugins"; # command line config my %opts; GetOptions \%opts, qw/ conf=s server=s port=i /; # reverse mapping; ( pid => job-name ) my %pids; # mapping of what we expect to die soon my %todie; # called if we die sub fail { foreach my $pid (keys %pids) { kill 15, $pid; } $_[0] ||= 'no reason provided'; die "shutdown detected: $_[0]\n"; } # now daemonize Danga::Daemon::daemonize( \&worker, { interval => 1, listenport => 13500, chdir => $path, shutdowncode => \&fail, }, ); # ( plugin-name => # { # modtime => int, # last modified time of plugin file (for reload detection) # children => [ pid, pid, pid, ... ], # pids of workers doing this plugin # file => str, # filename # reload => bool, # if on, reload_plugins() will reload this plugin # registered => bool, # if on, this plugin has registered correctly and is good to go # code => { # register => sub { ... }, # worker => sub { ... }, # }, # } # ); my %plugins; # ( job-name => # { # plugin => plugin-name, # respawn_with => plugin-name, # pid => pid, # options => [ option, option, option, ... ], # list of options from config file # reload => bool, # if on, reload_plugins() will reload this job # active => bool, # if on, this is active per the config file # } # ); my %jobs; # array of plugins that want to help us out; array of [ pluginname, [ opts ] ] my @helpers; # cached socket to our stat server my $sock; sub set { # sends a statistic up to the stat server $sock ||= IO::Socket::INET->new(PeerAddr => $opts{server}, PeerPort => $opts{port},); fail("can't create socket: $!") unless $sock; # send this on up to the server while (scalar(@_) >= 2) { $sock->print("QSET $_[0] $_[1]\r\n"); shift @_; shift @_; } } # keeps track of whether or not this is the first worker run sub worker { # step 1: check through plugins directory to see what plugins have changed (or are new) check_plugin_modtimes(); # step 2: check for a changed config file and load/reload as necessary check_config(); # step 3: now that we know exactly what's changed, call master reloader reload(); # step 4: run through any defined helpers so they can, well, help foreach my $helpref (@helpers) { my ($plugin, $opts) = @$helpref; next unless $plugins{$plugin} && $plugins{$plugin}->{registered} && $plugins{$plugin}->{code}->{helper}; eval { $plugins{$plugin}->{code}->{helper}->($opts); }; debug($@) if $@; } } sub check_plugin_modtimes { opendir DIR, $plugins_path or fail("Unable to open plugins directory for reading"); foreach my $file (readdir(DIR)) { next if $file =~ /^\./; next unless $file =~ /^(.+)\.pl$/; my $plugin = $1; # create an empty record if this is the first time we've found this plugin $plugins{$plugin} ||= { modtime => 0, reload => 1, registered => 0, children => [], file => "$plugins_path/$file", }; # compare modtime and mark for reload if necessary my $mt = (stat($plugins{$plugin}->{file}))[9]; $plugins{$plugin}->{reload} = 1 if $mt > $plugins{$plugin}->{modtime}; $plugins{$plugin}->{modtime} = $mt; } closedir DIR; } sub check_config { fail("Config file not found") unless -e $opts{conf}; my $mt = (stat($opts{conf}))[9]; $opts{config_modtime} ||= 0; reload_config() if $mt > $opts{config_modtime}; $opts{config_modtime} = $mt; } sub reload_config { debug(""); debug("configuration file reloading"); # we mark all jobs as inactive, so they get marked as active below foreach (keys %jobs) { $jobs{$_}->{active} = 0 unless $jobs{$_}->{respawn_with}; } # clear out all helpers, as they should get readded. they aren't in # separate threads so it doesn't matter if we readd them every time. @helpers = (); open FILE, "<$opts{conf}" or fail("Unable to open config file: $!"); foreach my $line () { # ignore comments and clean surrounding whitespace next if $line =~ /^\s*#/; $line =~ s/^\s+//; $line =~ s/[\s\r\n]+$//; next unless length $line; # shortcut; set some options if ($line =~ /^(\w+)\s*=\s*(.+)$/) { $opts{$1} = $2; next; } # extract any options contained in (...) and going to the end of the line my $optionstr; if ($line =~ s/(?:\((.*)\))$//) { $optionstr = $1; } my @options = map { (s/^\s+//, s/\s$//, 1) ? $_ : undef } # now trim whitespace front and back split(/,/, $optionstr); # split option string on commas # now see if it has a job + plugin left, or just a plugin if ($line =~ /^([-\w:.]+)\s*:\s*(\w+)$/) { # this is a job definition my ($job, $plugin) = ($1, $2); fail("Error adding $job to job list") unless add_job($job, $plugin, \@options); } elsif ($line =~ /^(\w+)$/) { # this is just a helper plugin fail("Plugin $1 not defined") unless $plugins{$1}; # push name of plugin on helper list debug("helper from plugin $1 added"); push @helpers, [ $1, \@options ]; } else { fail("Unknown format: $line($optionstr)"); } } close FILE; debug("configuration file reloaded"); } # main processor that goes through everything we have and reloads as necessary. this # also handles reaping our children. sub reload { # iterate over any dead children we might have picked up while ((my $pid = waitpid(-1, WNOHANG)) > 0) { if (my $job = delete($pids{$pid})) { if ($jobs{$job}->{active}) { debug("[$job] dead; pid = $pid; marking to reload; unexpected!"); $jobs{$job}->{reload} = 1; $jobs{$job}->{pid} = 0; } else { debug("[$job] dead; pid = $pid; inactive job, NOT reloading"); delete $jobs{$job}; } } else { if (delete $todie{$pid}) { debug("child death; pid = $pid; expected death, already reloaded"); } else { debug("ERROR: $pid died but we have no record of it"); } } } # iterate over plugins and reload as necessary foreach my $plugin (sort keys %plugins) { next unless $plugins{$plugin}->{reload}; debug("reloading plugin: $plugin"); # now require the file my $file = $plugins{$plugin}->{file}; unless (my $ret = do $file) { if ($@) { warn "couldn't parse $file: $@\n"; } elsif (! defined $ret) { warn "couldn't do $file: $!\n"; } else { warn "couldn't run $file\n"; } next; } # now mark any jobs with this plugin to reload foreach my $job (keys %jobs) { $jobs{$job}->{reload} = 1 if $jobs{$job}->{plugin} eq $plugin || $jobs{$job}->{respawn_with} eq $plugin; } } # now that we know all the plugins are loaded, iterate over jobs so we can get # the plugins spawned and doing something foreach my $job (sort keys %jobs) { my $plugin = $plugins{$jobs{$job}->{plugin}}; fail("can't add job for plugin with no worker code: job = $job; plugin = $jobs{$job}->{plugin}") unless ref $plugin->{code}->{worker}; # see if we need to kill off this job unless ($jobs{$job}->{active}) { debug("killing job: $job"); if ($jobs{$job}->{pid}) { kill 15, $jobs{$job}->{pid}; } else { delete $pids{$jobs{$job}->{pid}}; delete $jobs{$job}; } next; } # now, the following path does a reload of this job if necessary next unless $jobs{$job}->{reload} && $plugin->{registered}; debug("reloading job: $job"); # kill off this child if we had one if ($jobs{$job}->{pid}) { kill 15, $jobs{$job}->{pid}; delete $pids{$jobs{$job}->{pid}}; $todie{$jobs{$job}->{pid}} = 1; debug("[$job] killing child; pid = $jobs{$job}->{pid}"); $jobs{$job}->{pid} = 0; } # bust out a child for this job my $pid = fork; fail("can't fork child: $!") if !defined $pid; unless ($pid) { # child path; do some basic setup and then call the worker $0 .= " [$jobs{$job}->{plugin}: $job]"; $SIG{INT} = undef; # in case parent is in the foreground $SIG{TERM} = undef; # no special handling for this # call the child which should do all the work and return when it's done $plugin->{code}->{worker}->($job, $jobs{$job}->{options}); # when the above returns, the worker is done, so we exit exit 0; } # if we get here we're a parent, which means we need to mark this child as # run and that we don't need to do anything more $jobs{$job}->{pid} = $pid; $jobs{$job}->{reload} = 0; $pids{$pid} = $job; } } # called by plugins to let us know that they want to be active. they have to provide a # certain set of minimum functionality which we use. we also import some things into # their namespace. sub register_plugin { my ($plugin, $package, $opts) = @_; return unless $plugin && $package && $plugins{$plugin} && $opts; # make sure they gave us enough functions unless (ref $opts->{register} && (ref $opts->{worker} || ref $opts->{helper})) { debug("${plugin} did not provide minimum functionality: register and either worker or helper"); return; } # now create some aliases in their package so they can get to debug and set eval "*${package}::debug = *main::debug;"; eval "*${package}::set = *main::set;"; eval "*${package}::add_job = *main::add_job;"; eval "*${package}::get_var = *main::get_var;"; eval "*${package}::mark_inactive_by_plugin = *main::mark_inactive_by_plugin;"; # call the plugin's register function so that it knows we've acknowledged its presence unless ($opts->{register}->()) { debug("${plugin}::register() didn't return true"); return; } # done reloading, mark as reloaded (so we don't reload next time) $plugins{$plugin}->{code} = $opts; $plugins{$plugin}->{reload} = 0; $plugins{$plugin}->{registered} = 1; } # called by us and by helpers to add jobs to the list. if called by a plugin, $respawn_with # must be specified and should be set to the name of the plugin. otherwise, this job will # die the next time the config file is changed. sub add_job { my ($job, $plugin, $options, $respawn_with) = @_; fail("Bad input to add_job: job = $job, plugin = $plugin") unless $job && $plugin; $options ||= []; # now print out debugging info #debug("found config: $job: $plugin(" . join(', ', @$options) . ")"); # make sure this plugin exists fail("Plugin $plugin not defined") unless $plugins{$plugin}; # default %jobs setup $jobs{$job} ||= { plugin => $plugin, pid => 0, reload => 1, options => $options, respawn_with => $respawn_with, }; $jobs{$job}->{active} = 1; # on unconditionally # now determine if this job needs reloading $jobs{$job}->{reload} = 1 unless $jobs{$job}->{pid}; if (scalar(@$options) == scalar(@{$jobs{$job}->{options}})) { # compare options one by one, reload if any have changed for (my $i = 0; $i < scalar(@$options); $i++) { $jobs{$job}->{reload} = 1 if $options->[$i] ne $jobs{$job}->{options}->[$i]; } } else { # number of options changed, reload them all $jobs{$job}->{reload} = 1; } # if reload, copy in new options just in case if ($jobs{$job}->{reload}) { @{$jobs{$job}->{options}} = @$options; } return 1; } # called by helpers to mark everything they've spawned as inactive before # they begin another round of adding jobs. this is basically a way to say # to the gatherer that a process is dead. if it's not re-added immediately # by the helper, it gets killed off in the next round of reaping. sub mark_inactive_by_plugin { my $plugin = shift; foreach my $job (keys %jobs) { $jobs{$job}->{active} = 0 if $jobs{$job}->{respawn_with} eq $plugin; } } # used by plugins to get access to variables set in the config file sub get_var { return $opts{$_[0] || ''}; }