#!/usr/bin/env node // CLI for Storage System // Copyright (c) 2015 Joseph Huckaby // Released under the MIT License var path = require('path'); var cp = require('child_process'); var os = require('os'); var fs = require('fs'); var async = require('async'); var bcrypt = require('bcrypt-node'); var Args = require('pixl-args'); var Tools = require('pixl-tools'); var StandaloneStorage = require('pixl-server-storage/standalone'); // chdir to the proper server root dir process.chdir( path.dirname( __dirname ) ); // load app's config file var config = require('../conf/config.json'); // shift commands off beginning of arg array var argv = JSON.parse( JSON.stringify(process.argv.slice(2)) ); var commands = []; while (argv.length && !argv[0].match(/^\-/)) { commands.push( argv.shift() ); } // now parse rest of cmdline args, if any var args = new Args( argv, { debug: false, verbose: false, quiet: false } ); args = args.get(); // simple hash // copy debug flag into config (for standalone) config.Storage.debug = args.debug; var print = function(msg) { // print message to console if (!args.quiet) process.stdout.write(msg); }; var verbose = function(msg) { // print only in verbose mode if (args.verbose) print(msg); }; var warn = function(msg) { // print to stderr unless quiet if (!args.quiet) process.stderr.write(msg); }; var verbose_warn = function(msg) { // verbose print to stderr unless quiet if (args.verbose && !args.quiet) process.stderr.write(msg); }; if (config.uid && (process.getuid() != 0)) { print( "ERROR: Must be root to use this script.\n" ); process.exit(1); } // determine server hostname var hostname = (process.env['HOSTNAME'] || process.env['HOST'] || os.hostname()).toLowerCase(); // find the first external IPv4 address var ip = ''; var ifaces = os.networkInterfaces(); var addrs = []; for (var key in ifaces) { if (ifaces[key] && ifaces[key].length) { Array.from(ifaces[key]).forEach( function(item) { addrs.push(item); } ); } } var addr = Tools.findObject( addrs, { family: 'IPv4', internal: false } ); if (addr && addr.address && addr.address.match(/^\d+\.\d+\.\d+\.\d+$/)) { ip = addr.address; } else { print( "ERROR: Could not determine server's IP address.\n" ); process.exit(1); } // util.isArray is DEPRECATED??? Nooooooooode! var isArray = Array.isArray || util.isArray; // prevent logging transactions to STDOUT config.Storage.log_event_types = {}; // allow APPNAME_key env vars to override config var env_regex = new RegExp( "^CRONICLE_(.+)$" ); for (var env_key in process.env) { if (env_key.match(env_regex)) { var env_path = RegExp.$1.trim().replace(/^_+/, '').replace(/_+$/, '').replace(/__/g, '/'); var env_value = process.env[env_key].toString(); // massage value into various types if (env_value === 'true') env_value = true; else if (env_value === 'false') env_value = false; else if (env_value.match(/^\-?\d+$/)) env_value = parseInt(env_value); else if (env_value.match(/^\-?\d+\.\d+$/)) env_value = parseFloat(env_value); Tools.setPath(config, env_path, env_value); } } // construct standalone storage server var storage = new StandaloneStorage(config.Storage, function(err) { if (err) throw err; // storage system is ready to go // become correct user if (config.uid && (process.getuid() == 0)) { verbose( "Switching to user: " + config.uid + "\n" ); process.setuid( config.uid ); } // custom job data expire handler storage.addRecordType( 'cronicle_job', { 'delete': function(key, value, callback) { storage.delete( key, function(err) { storage.delete( key + '/log.txt.gz', function(err) { callback(); } ); // delete } ); // delete } } ); // process command var cmd = commands.shift(); verbose("\n"); switch (cmd) { case 'setup': case 'install': // setup new master server var setup = require('../conf/setup.json'); // make sure this is only run once storage.get( 'global/users', function(err) { if (!err) { print( "Storage has already been set up. There is no need to run this command again.\n\n" ); process.exit(1); } async.eachSeries( setup.storage, function(params, callback) { verbose( "Executing: " + JSON.stringify(params) + "\n" ); // [ "listCreate", "global/users", { "page_size": 100 } ] var func = params.shift(); params.push( callback ); // massage a few params if (typeof(params[1]) == 'object') { var obj = params[1]; if (obj.created) obj.created = Tools.timeNow(true); if (obj.modified) obj.modified = Tools.timeNow(true); if (obj.regexp && (obj.regexp == '_HOSTNAME_')) obj.regexp = '^(' + Tools.escapeRegExp( hostname ) + ')$'; if (obj.hostname && (obj.hostname == '_HOSTNAME_')) obj.hostname = hostname; if (obj.ip && (obj.ip == '_IP_')) obj.ip = ip; } // call storage directly storage[func].apply( storage, params ); }, function(err) { if (err) throw err; print("\n"); print( "Setup completed successfully!\n" ); print( "This server ("+hostname+") has been added as the single primary master server.\n" ); print( "An administrator account has been created with username 'admin' and password 'admin'.\n" ); print( "You should now be able to start the service by typing: '/opt/cronicle/bin/control.sh start'\n" ); print( "Then, the web interface should be available at: http://"+hostname+":"+config.WebServer.http_port+"/\n" ); print( "Please allow for up to 60 seconds for the server to become master.\n\n" ); storage.shutdown( function() { process.exit(0); } ); } ); } ); break; case 'admin': // create or replace admin account // Usage: ./storage-cli.js admin USERNAME PASSWORD [EMAIL] var username = commands.shift(); var password = commands.shift(); var email = commands.shift() || 'admin@localhost'; if (!username || !password) { print( "\nUsage: bin/storage-cli.js admin USERNAME PASSWORD [EMAIL]\n\n" ); process.exit(1); } if (!username.match(/^[\w\-\.]+$/)) { print( "\nERROR: Username must contain only alphanumerics, dash and period.\n\n" ); process.exit(1); } username = username.toLowerCase(); var user = { username: username, password: password, full_name: "Administrator", email: email }; user.active = 1; user.created = user.modified = Tools.timeNow(true); user.salt = Tools.generateUniqueID( 64, user.username ); user.password = bcrypt.hashSync( user.password + user.salt ); user.privileges = { admin: 1 }; storage.put( 'users/' + username, user, function(err) { if (err) throw err; print( "\nAdministrator '"+username+"' created successfully.\n" ); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'get': case 'fetch': case 'view': case 'cat': // get storage key // Usage: ./storage-cli.js get users/jhuckaby var key = commands.shift(); storage.get( key, function(err, data) { if (err) throw err; if (storage.isBinaryKey(key)) print( data.toString() + "\n" ); else print( ((typeof(data) == 'object') ? JSON.stringify(data, null, "\t") : data) + "\n" ); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'put': case 'save': case 'store': // put storage key (read data from STDIN) // Usage: cat USER.json | ./storage-cli.js put users/jhuckaby var key = commands.shift(); var json_raw = ''; var rl = require('readline').createInterface({ input: process.stdin }); rl.on('line', function(line) { json_raw += line; }); rl.on('close', function() { print( "Writing record from STDIN: " + key + "\n" ); var data = null; try { data = JSON.parse(json_raw); } catch (err) { warn( "Failed to parse JSON for key: " + key + ": " + err + "\n" ); process.exit(1); } storage.put( key, data, function(err) { if (err) { warn( "Failed to store record: " + key + ": " + err + "\n" ); process.exit(1); } print("Record successfully saved: "+key+"\n"); storage.shutdown( function() { process.exit(0); } ); } ); }); break; case 'edit': case 'vi': var key = commands.shift(); if ((cmd == 'edit') && !process.env.EDITOR) { warn( "No EDITOR environment variable is set.\n" ); process.exit(1); } storage.get( key, function(err, data) { if (err) data = {}; print("Spawning editor to edit record: " + key + "\n"); // save to local temp file var temp_file = path.join( os.tmpdir(), 'cli-temp-' + process.pid + '.json' ); fs.writeFileSync( temp_file, JSON.stringify(data, null, "\t") + "\n" ); var stats = fs.statSync( temp_file ); var old_mod = Math.floor( stats.mtime.getTime() / 1000 ); // spawn vi but inherit terminal var child = cp.spawn( (cmd == 'vi') ? 'vi' : process.env.EDITOR, [temp_file], { stdio: 'inherit' } ); child.on('exit', function (e, code) { var stats = fs.statSync( temp_file ); var new_mod = Math.floor( stats.mtime.getTime() / 1000 ); if (new_mod != old_mod) { print("Saving new data back into record: "+key+"\n"); var json_raw = fs.readFileSync( temp_file, { encoding: 'utf8' } ); fs.unlinkSync( temp_file ); var data = JSON.parse( json_raw ); storage.put( key, data, function(err, data) { if (err) throw err; print("Record successfully saved with your changes: "+key+"\n"); storage.shutdown( function() { process.exit(0); } ); } ); } else { fs.unlinkSync( temp_file ); print("File has not been changed, record was not touched: "+key+"\n"); storage.shutdown( function() { process.exit(0); } ); } } ); } ); // got data break; case 'delete': // delete storage key // Usage: ./storage-cli.js delete users/jhuckaby var key = commands.shift(); storage.delete( key, function(err, data) { if (err) throw err; print("Record '"+key+"' deleted successfully.\n"); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'list_create': // create new list // Usage: ./storage-cli.js list_create key var key = commands.shift(); storage.listCreate( key, null, function(err) { if (err) throw err; print("List created successfully: " + key + "\n"); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'list_pop': // pop item off end of list // Usage: ./storage-cli.js list_pop key var key = commands.shift(); storage.listPop( key, function(err, item) { if (err) throw err; print("Item popped off list: " + key + ": " + JSON.stringify(item, null, "\t") + "\n"); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'list_get': // fetch items from list // Usage: ./storage-cli.js list_get key idx len var key = commands.shift(); var idx = parseInt( commands.shift() || 0 ); var len = parseInt( commands.shift() || 0 ); storage.listGet( key, idx, len, function(err, items) { if (err) throw err; print("Got " + items.length + " items.\n"); print("Items from list: " + key + ": " + JSON.stringify(items, null, "\t") + "\n"); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'list_info': // fetch info about list // Usage: ./storage-cli.js list_info key var key = commands.shift(); storage.listGetInfo( key, function(err, list) { if (err) throw err; print("List Header: " + key + ": " + JSON.stringify(list, null, "\t") + "\n\n"); var page_idx = list.first_page; var item_idx = 0; async.whilst( function() { return page_idx <= list.last_page; }, function(callback) { // load each page storage._listLoadPage(key, page_idx++, false, function(err, page) { if (err) return callback(err); print("Page " + Math.floor(page_idx - 1) + ": " + page.items.length + " items\n"); callback(); } ); // page loaded }, function(err) { // all pages iterated if (err) throw err; print("\n"); storage.shutdown( function() { process.exit(0); } ); } // pages complete ); // whilst } ); break; case 'list_delete': // delete list // Usage: ./storage-cli.js list_delete key var key = commands.shift(); storage.listDelete( key, null, function(err) { if (err) throw err; print("List deleted successfully: " + key + "\n"); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'maint': case 'maintenance': // perform daily maintenance, specify date or defaults to current day // Usage: ./storage-cli.js maint 2015-05-31 storage.runMaintenance( commands.shift(), function() { print( "Daily maintenance completed successfully.\n" ); print("\n"); storage.shutdown( function() { process.exit(0); } ); } ); break; case 'export': // export all storage data (except completed jobs, sessions) var file = commands.shift(); export_data(file); break; case 'import': // import storage data from file var file = commands.shift(); import_data(file); break; default: print("Unknown command: " + cmd + "\n"); storage.shutdown( function() { process.exit(0); } ); break; } // switch }); function export_data(file) { // export data to file or stdout (except for completed jobs, logs, and sessions) // one record per line: KEY - JSON var stream = file ? fs.createWriteStream(file) : process.stdout; // file header (for humans) var file_header = "# Cronicle Data Export v1.0\n" + "# Hostname: " + hostname + "\n" + "# Date/Time: " + (new Date()).toString() + "\n" + "# Format: KEY - JSON\n\n"; stream.write( file_header ); verbose_warn( file_header ); if (file) verbose_warn("Exporting to file: " + file + "\n\n"); // need to handle users separately, as they're stored as a list + individual records storage.listEach( 'global/users', function(item, idx, callback) { var username = item.username; var key = 'users/' + username.toString().toLowerCase().replace(/\W+/g, ''); verbose_warn( "Exporting user: " + username + "\n" ); storage.get( key, function(err, user) { if (err) { // user deleted? warn( "\nFailed to fetch user: " + key + ": " + err + "\n\n" ); return callback(); } stream.write( key + ' - ' + JSON.stringify(user) + "\n", 'utf8', callback ); } ); // get }, function(err) { // ignoring errors here // proceed to the rest of the lists async.eachSeries( [ 'global/users', 'global/plugins', 'global/categories', 'global/server_groups', 'global/schedule', 'global/servers', 'global/api_keys' ], function(list_key, callback) { // first get the list header verbose_warn( "Exporting list: " + list_key + "\n" ); storage.get( list_key, function(err, list) { if (err) return callback( new Error("Failed to fetch list: " + list_key + ": " + err) ); stream.write( list_key + ' - ' + JSON.stringify(list) + "\n" ); // now iterate over all the list pages var page_idx = list.first_page; async.whilst( function() { return page_idx <= list.last_page; }, function(callback) { // load each page var page_key = list_key + '/' + page_idx; page_idx++; verbose_warn( "Exporting list page: " + page_key + "\n"); storage.get(page_key, function(err, page) { if (err) return callback( new Error("Failed to fetch list page: " + page_key + ": " + err) ); // write page data stream.write( page_key + ' - ' + JSON.stringify(page) + "\n", 'utf8', callback ); } ); // page get }, // iterator callback ); // whilst } ); // get }, // iterator function(err) { if (err) { warn( "\nEXPORT ERROR: " + err + "\n" ); process.exit(1); } verbose_warn( "\nExport completed at " + (new Date()).toString() + ".\nExiting.\n\n" ); if (file) stream.end(); storage.shutdown( function() { process.exit(0); } ); } // done done ); // list eachSeries } // done with users ); // users listEach }; function import_data(file) { // import storage data from specified file or stdin // one record per line: KEY - JSON print( "\nCronicle Data Importer v1.0\n" ); if (file) print( "Importing from file: " + file + "\n" ); else print( "Importing from STDIN\n" ); print( "\n" ); var count = 0; var queue = async.queue( function(line, callback) { // process each line if (line.match(/^(\w[\w\-\.\/]*)\s+\-\s+(\{.+\})\s*$/)) { var key = RegExp.$1; var json_raw = RegExp.$2; print( "Importing record: " + key + "\n" ); var data = null; try { data = JSON.parse(json_raw); } catch (err) { warn( "Failed to parse JSON for key: " + key + ": " + err + "\n" ); return callback(); } storage.put( key, data, function(err) { if (err) { warn( "Failed to store record: " + key + ": " + err + "\n" ); return callback(); } count++; callback(); } ); } else callback(); }, 1 ); // setup readline to line-read from file or stdin var readline = require('readline'); var rl = readline.createInterface({ input: file ? fs.createReadStream(file) : process.stdin }); rl.on('line', function(line) { // enqueue each line queue.push( line ); }); rl.on('close', function() { // end of input stream var complete = function() { // finally, delete state so cronicle recreates it storage.delete( 'global/state', function(err) { // ignore error here, as state may not exist yet print( "\nImport complete. " + count + " records imported.\nExiting.\n\n" ); storage.shutdown( function() { process.exit(0); } ); }); }; // fire complete on queue drain if (queue.idle()) complete(); else queue.drain = complete; }); // rl close };