Skip to content
Browse files

first commit

  • Loading branch information...
0 parents commit 9d64c05be57f21737d528d8b169a9420ecd4063b @jvinet jvinet committed Oct 11, 2011
Showing with 593 additions and 0 deletions.
  1. +14 −0 README.md
  2. +32 −0 config.js
  3. +168 −0 janitor.js
  4. +231 −0 lib/node-ext.js
  5. +114 −0 lib/node-mailer.js
  6. +11 −0 package.json
  7. +11 −0 test/job1.sh
  8. +6 −0 test/job2.sh
  9. +6 −0 test/job3.sh
14 README.md
@@ -0,0 +1,14 @@
+## Janitor
+
+It keeps the things running at the right intervals. But different than cron.
+
+With Janitor, you can schedule commands to run at specific intervals (eg,
+every 30 seconds). You can also schedule commands to run after other
+commands have completed.
+
+At Bet Smart, we use the Janitor to run a number of post-processing
+tasks after a data feed has been consumed.
+
+Janitor also provides basic watchdog capabilities. If a process dies,
+Janitor will restart it and notify the admin.
+
32 config.js
@@ -0,0 +1,32 @@
+exports.admin_email = 'admin@example.com';
+
+/**
+ * schedule: <arbitrary name> : { cmd:<what to run>, when:<when to run>, args:['last_run'] }
+ * watch: <arbitrary name> : { cmd:<what to run> }
+ *
+var APP_BASE = '/home/sites/myapp.com/htdocs';
+exports.config = {
+ schedule: {
+ some_cmd: {cmd:APP_BASE+'/app/bin/cmd.php', args:['last_run'], when:'every 300s'},
+ another_cmd: {cmd:APP_BASE+'/app/bin/cmd.py', when:'every 60s'},
+ one_more_cmd: {cmd:APP_BASE+'/app/bin/and_then.py', when:'after some_cmd'},
+ },
+ watch: {
+ sketchy_cmd: {cmd:APP_BASE+'/app/bin/sometimes_crashes.php', notify:true}
+ }
+};*/
+
+/**
+ * Testing
+ */
+exports.config = {
+ schedule: {
+ t: {cmd:'test/env.php', when:'every 2s'},
+ job1: {cmd:'test/job1.sh', args:['last_run'], when:'every 2s'},
+ job2: {cmd:'test/job2.sh', args:['last_run'], when:'after job1'},
+ job3: {cmd:'test/job3.sh', args:['last_run'], when:'after job1'},
+ },
+ watch: {
+ job1: {cmd:'test/job1.sh', notify:false}
+ }
+};
168 janitor.js
@@ -0,0 +1,168 @@
+/**
+ * Janitor
+ *
+ * Copyright (C) 2011 Bet Smart Media <http://www.betsmartmedia.com>
+ *
+ * It keeps the things running at the right intervals. But different than cron.
+ *
+ * With Janitor, you can schedule commands to run at specific intervals (eg,
+ * every 30 seconds). You can also schedule commands to run after other
+ * commands have completed.
+ *
+ * Janitor also provides basic watchdog functionality. If a process is not
+ * running, it will be restarted.
+ *
+ * This code works on NodeJS 0.4.18.
+ *
+ * TODO: use nodules for hot-loading config?
+ * TODO: use forever/daemon
+ */
+
+var sys = require("sys");
+var cproc = require("child_process");
+var mailer = require("./lib/node-mailer");
+var ext = require("./lib/node-ext");
+
+var VERSION = '1.1.4';
+
+var hostname = '';
+cproc.exec('hostname -f', function(err, stdout, stderr){
+ hostname = stdout;
+});
+
+// load config
+var C;
+var name = process.argv[2] ? process.argv[2] : './config';
+var C = require(name);
+var CONFIG = C.config
+var ADMIN_EMAIL = C.admin_email;
+
+// init state
+STATE = {schedule:{}, watch:{}};
+for(var x in CONFIG.schedule) STATE.schedule[x] = {running: false, last_run: new Date("1980/01/01 00:00:00")};
+for(var x in CONFIG.watch) STATE.watch[x] = {pid: 0, last_restart: 0};
+
+function log(str) {
+ var now = new Date().format("yyyy-mm-dd HH:MM:ss");
+ sys.puts("[" + now + "] " + str);
+}
+
+/**
+ * Watch any active jobs in STATE.watch.
+ */
+function watch_jobs() {
+ var chkpid = function(p, cb) {
+ if(p < 1) return cb(false);
+
+ cproc.exec('ps -p '+p, function(err, stdout, stderr){
+ if(err) return cb(false);
+ cb(true);
+ });
+ };
+
+ for(var x in STATE.watch) (function(x){
+ chkpid(STATE.watch[x].pid, function(is_running){
+ if(is_running) return;
+
+ sys.puts(x+" is not running, restarting");
+ var c = cproc.spawn(CONFIG.watch[x].cmd);
+ STATE.watch[x].pid = c.pid;
+ sys.puts(" pid: "+c.pid);
+
+ if(CONFIG.watch[x].notify) {
+ mailer.send({
+ to: ADMIN_EMAIL,
+ from: ADMIN_EMAIL,
+ subject: 'BSM | Janitor',
+ body: "Hostname: "+hostname+"\n\nProcess restarted: "+x+" (pid:"+c.pid+")\n"
+ });
+ }
+ });
+ })(x);
+}
+
+/**
+ * Run a scheduled job. When the job completes, look for other jobs ("sub-jobs")
+ * that should run after this one, and execute them.
+ *
+ * If a sub-job is already running, then it is completely bypassed for this
+ * dispatch cycle.
+ */
+function run_job(x) {
+ var state = STATE.schedule[x],
+ cfg = CONFIG.schedule[x];
+ if(state.running) return sys.puts("... "+x+" is still running, skipping");
+
+ var cmd = cfg.cmd;
+ if(cfg.args) cfg.args.forEach(function(it){
+ switch(it) {
+ case 'last_run': cmd += ' "' + state.last_run.format("yyyy-mm-dd HH:MM:ss") + '"'; break;
+ default: sys.puts("Unrecognized dyn arg: "+it);
+ }
+ });
+ STATE.schedule[x].running = true;
+ STATE.schedule[x].last_run = new Date();
+ log("exec " + x + ": " + cmd);
+ cproc.exec(cmd, {env: {IN_JANITOR:1}}, function(err, stdout, stderr){
+ STATE.schedule[x].running = false;
+ if(err) {
+ mailer.send({
+ to: ADMIN_EMAIL,
+ from: ADMIN_EMAIL,
+ subject: 'BSM | Janitor',
+ body: "Command returned an error.\n\nError: "+err+"\n\nHostname: "+hostname+"\nCommand: "+CONFIG.schedule[x].cmd+"\n\n"+sys.inspect(arguments)
+ });
+ sys.puts(x+": Gadzooks! Error!");
+ sys.puts(sys.inspect(arguments));
+ } else {
+ log(x+": finished");
+ //process.stdio.write(stdout);
+ if(stderr) {
+ mailer.send({
+ to: ADMIN_EMAIL,
+ from: ADMIN_EMAIL,
+ subject: 'BSM | Janitor',
+ body: "Command returned some output on stderr.\n\nHostname: "+hostname+"\nCommand: "+CONFIG.schedule[x].cmd+"\n\n"+stderr
+ });
+ }
+ }
+
+ // find jobs that want to be run after this job, and execute them
+ for(var y in CONFIG.schedule) (function(y){
+ var m = /^after (.*)$/.exec(CONFIG.schedule[y].when);
+ if(!m || m[1] != x) return;
+ run_job(y);
+ })(y);
+ });
+}
+
+/**
+ * Run jobs that should execute every X seconds
+ */
+function dispatch() {
+ var now = new Date();
+
+ // wrapping the guts of the loop in a function forces earlier
+ // scope binding, which fixes the closures-in-loops gotcha.
+ for(var x in CONFIG.schedule) (function(x){
+ var m = /^every (.*)([smhd])$/.exec(CONFIG.schedule[x].when);
+ if(!m) return;
+
+ switch(m[2]) {
+ case 's': var mult = 1; break;
+ case 'm': var mult = 60; break;
+ case 'h': var mult = 3600; break;
+ case 'd': var mult = 86400;
+ }
+
+ if(STATE.schedule[x].last_run <= new Date(now - (m[1] * mult * 1000))) {
+ run_job(x);
+ }
+ })(x);
+
+ watch_jobs();
+}
+
+sys.puts("Janitor v"+VERSION+" starting...");
+dispatch();
+setInterval(dispatch, 5000); // every 5 seconds
231 lib/node-ext.js
@@ -0,0 +1,231 @@
+/*****************************************************************
+ * EXTENSIONS
+ *****************************************************************/
+
+/**
+ * Calculate the amount of time from the date until now. Only
+ * returns the most significant unit (day/hour/minute/second),
+ * rounded down.
+ */
+Date.prototype.fromNow = function() {
+ var now = new Date();
+ var diff = parseInt((now.getTime() - this.getTime()) / 1000);
+ var d = parseInt(diff / 86400); diff -= d * 86400;
+ var h = parseInt(diff / 3600); diff -= h * 3600;
+ var m = parseInt(diff / 60); diff -= m * 60;
+ var s = diff;
+ if(d > 0) return d+" day"+(d > 1 ? 's' : '')+" ago";
+ if(h > 0) return h+" hour"+(h > 1 ? 's' : '')+" ago";
+ if(m > 0) return m+" minute"+(m > 1 ? 's' : '')+" ago";
+ if(s > 0) return s+" second"+(s > 1 ? 's' : '')+" ago";
+ return "now";
+};
+
+/**
+ * Trim the first part of a string up to the first occurrence of
+ * a character. Return the whole string if the character is not found.
+ */
+String.prototype.trimTo = function(c) {
+ return this.indexOf(c) > -1 ? this.substring(0, this.indexOf(c)) : this;
+};
+
+/**
+ * Append one array to another
+ */
+Array.prototype.append = function(a) {
+ for(var i = 0; i < a.length; i++) {
+ this.push(a[i]);
+ }
+};
+
+/**
+ * Prepend one array to another
+ */
+Array.prototype.prepend = function(a) {
+ for(var i = a.length-1; i >= 0; i--) {
+ this.unshift(a[i]);
+ }
+};
+
+/**
+ * sprintf and vsprintf for Javascript
+ * based on Copyright (c) 2008 Sabin Iacob (m0n5t3r) <iacobs@m0n5t3r.info>
+ * which is somewhat based on http://jan.moesen.nu/code/javascript/sprintf-and-printf-in-javascript/
+ *
+ * GPL-licensed.
+ */
+var sprintf = {
+ formats: {
+ '%': function(val) {return '%';},
+ 'b': function(val) {return parseInt(val, 10).toString(2);},
+ 'c': function(val) {return String.fromCharCode(parseInt(val, 10));},
+ 'd': function(val) {return parseInt(val, 10) ? parseInt(val, 10) : 0;},
+ 'u': function(val) {return Math.abs(val);},
+ 'f': function(val, p) {return (p > -1) ? Math.round(parseFloat(val) * Math.pow(10, p)) / Math.pow(10, p): parseFloat(val);},
+ 'o': function(val) {return parseInt(val, 10).toString(8);},
+ 's': function(val) {return val;},
+ 'x': function(val) {return ('' + parseInt(val, 10).toString(16)).toLowerCase();},
+ 'X': function(val) {return ('' + parseInt(val, 10).toString(16)).toUpperCase();}
+ },
+
+ re: /%(?:(\d+)?(?:\.(\d+))?|\(([^)]+)\))([%bcdufosxX])/g,
+
+ dispatch: function(data){
+ if(data.length == 1 && typeof data[0] == 'object') { //python-style printf
+ data = data[0];
+ return function(match, w, p, lbl, fmt, off, str) {
+ with(sprintf) return formats[fmt](data[lbl]);
+ };
+ } else { // regular, somewhat incomplete, printf
+ var idx = 0; // oh, the beauty of closures :D
+ return function(match, w, p, lbl, fmt, off, str) {
+ with(sprintf) return formats[fmt](data[idx++], p);
+ };
+ }
+ },
+
+ sprintf: function(format) {
+ var argv = Array.apply(null, arguments).slice(1);
+ with(sprintf) return format.replace(re, dispatch(argv));
+ },
+
+ vsprintf: function(format, data) {
+ with(sprintf) return format.replace(re, dispatch(data));
+ }
+};
+exports.sprintf = sprintf.sprintf;
+exports.vsprintf = sprintf.vsprintf;
+String.prototype.sprintf = function() {
+ return sprintf.vsprintf(this, arguments);
+};
+String.prototype.vsprintf = function(args) {
+ return sprintf.vsprintf(this, args);
+}
+
+/*
+ * Date Format 1.2.3
+ * (c) 2007-2009 Steven Levithan <stevenlevithan.com>
+ * MIT license
+ *
+ * Includes enhancements by Scott Trenda <scott.trenda.net>
+ * and Kris Kowal <cixar.com/~kris.kowal/>
+ *
+ * Accepts a date, a mask, or a date and a mask.
+ * Returns a formatted version of the given date.
+ * The date defaults to the current date/time.
+ * The mask defaults to dateFormat.masks.default.
+ *
+ * See <http://blog.stevenlevithan.com/archives/date-time-format> for docs
+ */
+var dateFormat = function () {
+ var token = /d{1,4}|m{1,4}|yy(?:yy)?|([HhMsTt])\1?|[LloSZ]|"[^"]*"|'[^']*'/g,
+ timezone = /\b(?:[PMCEA][SDP]T|(?:Pacific|Mountain|Central|Eastern|Atlantic) (?:Standard|Daylight|Prevailing) Time|(?:GMT|UTC)(?:[-+]\d{4})?)\b/g,
+ timezoneClip = /[^-+\dA-Z]/g,
+ pad = function (val, len) {
+ val = String(val);
+ len = len || 2;
+ while (val.length < len) val = "0" + val;
+ return val;
+ };
+
+ // Regexes and supporting functions are cached through closure
+ return function (date, mask, utc) {
+ var dF = dateFormat;
+
+ // You can't provide utc if you skip other args (use the "UTC:" mask prefix)
+ if (arguments.length == 1 && Object.prototype.toString.call(date) == "[object String]" && !/\d/.test(date)) {
+ mask = date;
+ date = undefined;
+ }
+
+ // Passing date through Date applies Date.parse, if necessary
+ date = date ? new Date(date) : new Date;
+ if (isNaN(date)) throw SyntaxError("invalid date");
+
+ mask = String(dF.masks[mask] || mask || dF.masks["default"]);
+
+ // Allow setting the utc argument via the mask
+ if (mask.slice(0, 4) == "UTC:") {
+ mask = mask.slice(4);
+ utc = true;
+ }
+
+ var _ = utc ? "getUTC" : "get",
+ d = date[_ + "Date"](),
+ D = date[_ + "Day"](),
+ m = date[_ + "Month"](),
+ y = date[_ + "FullYear"](),
+ H = date[_ + "Hours"](),
+ M = date[_ + "Minutes"](),
+ s = date[_ + "Seconds"](),
+ L = date[_ + "Milliseconds"](),
+ o = utc ? 0 : date.getTimezoneOffset(),
+ flags = {
+ d: d,
+ dd: pad(d),
+ ddd: dF.i18n.dayNames[D],
+ dddd: dF.i18n.dayNames[D + 7],
+ m: m + 1,
+ mm: pad(m + 1),
+ mmm: dF.i18n.monthNames[m],
+ mmmm: dF.i18n.monthNames[m + 12],
+ yy: String(y).slice(2),
+ yyyy: y,
+ h: H % 12 || 12,
+ hh: pad(H % 12 || 12),
+ H: H,
+ HH: pad(H),
+ M: M,
+ MM: pad(M),
+ s: s,
+ ss: pad(s),
+ l: pad(L, 3),
+ L: pad(L > 99 ? Math.round(L / 10) : L),
+ t: H < 12 ? "a" : "p",
+ tt: H < 12 ? "am" : "pm",
+ T: H < 12 ? "A" : "P",
+ TT: H < 12 ? "AM" : "PM",
+ Z: utc ? "UTC" : (String(date).match(timezone) || [""]).pop().replace(timezoneClip, ""),
+ o: (o > 0 ? "-" : "+") + pad(Math.floor(Math.abs(o) / 60) * 100 + Math.abs(o) % 60, 4),
+ S: ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 != 10) * d % 10]
+ };
+
+ return mask.replace(token, function ($0) {
+ return $0 in flags ? flags[$0] : $0.slice(1, $0.length - 1);
+ });
+ };
+}();
+
+// Some common format strings
+dateFormat.masks = {
+ "default": "ddd mmm dd yyyy HH:MM:ss",
+ shortDate: "m/d/yy",
+ mediumDate: "mmm d, yyyy",
+ longDate: "mmmm d, yyyy",
+ fullDate: "dddd, mmmm d, yyyy",
+ shortTime: "h:MM TT",
+ mediumTime: "h:MM:ss TT",
+ longTime: "h:MM:ss TT Z",
+ isoDate: "yyyy-mm-dd",
+ isoTime: "HH:MM:ss",
+ isoDateTime: "yyyy-mm-dd'T'HH:MM:ss",
+ isoUtcDateTime: "UTC:yyyy-mm-dd'T'HH:MM:ss'Z'"
+};
+
+// Internationalization strings
+dateFormat.i18n = {
+ dayNames: [
+ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
+ ],
+ monthNames: [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"
+ ]
+};
+
+// For convenience...
+Date.prototype.format = function (mask, utc) {
+ return dateFormat(this, mask, utc);
+};
+exports.dateFormat = dateFormat;
114 lib/node-mailer.js
@@ -0,0 +1,114 @@
+/* Copyright (c) 2009 Marak Squires - www.maraksquires.com
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+/* Fixed by Judd, 2010-02-15 */
+/* Updated for Node 0.1.30, 2010-02-27 */
+
+/********* USAGE ***********
+
+ var email = require("./node_mailer");
+ email.send({
+ to : "marak.squires@gmail.com",
+ from : "obama@whitehouse.gov",
+ subject : "node_mailer test email",
+ body : "hello this is a test email from the node_mailer"
+ });
+
+****************************/
+
+var net = require('net');
+var sys = require('sys');
+
+var email = {
+ send:function (options, cb){
+ var options = typeof(options) == "undefined" ? {} : options;
+ options.to = typeof(options.to) == "undefined" ? "example@example.com" : options.to;
+ options.from = typeof(options.from) == "undefined" ? "noreply@example.com" : options.from;
+ options.subject = typeof(options.subject) == "undefined" ? "no subject" : options.subject;
+ options.body = typeof(options.body) == "undefined" ? "" : options.body;
+
+ var self = this;
+ var cb = cb || function(){};
+
+ this.connection = net.createConnection(25);
+ this.connection.addListener("connect", function (socket) {
+ self.connection.write("HELO localhost\r\n");
+ self.connection.write("MAIL FROM: " + options.from + "\r\n");
+ self.connection.write("RCPT TO: " + options.to + "\r\n");
+ self.connection.write("DATA\r\n");
+ self.connection.write("From: " + options.from + "\r\n");
+ self.connection.write("To: " + options.to + "\r\n");
+ self.connection.write("Subject: " + options.subject + "\r\n");
+ // don't use this unless you need to, it triggers spam traps
+ //self.connection.write("Content-Type: text/plain\r\n");
+ self.connection.write(email.wordwrap(options.body) + "\r\n");
+ self.connection.write(".\r\n");
+ self.connection.write("QUIT\r\n");
+ self.connection.end();
+ });
+
+ var output = '';
+
+ this.connection.addListener("receive", function (data) {
+ output += data;
+ });
+ this.connection.addListener("eof", function(){
+ if(email.parseResponse(output)){
+ cb(null);
+ }else{
+ sys.puts(sys.inspect(arguments));
+ cb(true);
+ }
+ });
+ },
+
+ parseResponse:function(data){
+ var success = false;
+ var d = data.split("\r\n");
+ d.forEach(function(itm){
+ // not sure if all MTAs respond with "250 2.0.0" but Sendmail and Postfix do
+ if(/^250 2\.0\.0/.test(itm)) success = true;
+ });
+ return success;
+ },
+
+ wordwrap:function(str){
+ var m = 80;
+ var b = "\r\n";
+ var c = false;
+ var i, j, l, s, r;
+ str += '';
+ if (m < 1) {
+ return str;
+ }
+ for (i = -1, l = (r = str.split(/\r\n|\n|\r/)).length; ++i < l; r[i] += s) {
+ for(s = r[i], r[i] = ""; s.length > m; r[i] += s.slice(0, j) + ((s = s.slice(j)).length ? b : "")){
+ j = c == 2 || (j = s.slice(0, m + 1).match(/\S*(\s)?$/))[1] ? m : j.input.length - j[0].length || c == 1 && m || j.input.length + (j = s.slice(m).match(/^\S*/)).input.length;
+ }
+ }
+ return r.join("\n");
+ }
+}
+
+exports.send = email.send;
11 package.json
@@ -0,0 +1,11 @@
+{
+ "author": "Judd Vinet <jvinet@gmail.com> (http://www.zeroflux.org)",
+ "name": "janitor",
+ "description": "A different sort of cron",
+ "version": "1.1.3",
+ "engines": {
+ "node": ">v0.2.3"
+ },
+ "dependencies": {},
+ "devDependencies": {}
+}
11 test/job1.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+echo "$0 $1"
+
+#if [ "$IN_JANITOR" ]; then
+# echo " envvar IN_JANITOR is set" >>log
+#fi
+
+sleep 2
+exit 0
+
6 test/job2.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+echo "$0 $1"
+sleep 5
+exit 0
+
6 test/job3.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+echo "$0 $1"
+sleep 5
+exit 0
+

0 comments on commit 9d64c05

Please sign in to comment.
Something went wrong with that request. Please try again.