diff --git a/TODO.txt b/TODO.txt index b1fae8f..ceeaa58 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,5 +1,6 @@ TritonApi + "shortid" instead of full UUID "id" in default output, and then allow lookup by that shortid. Really nice for 80 columns. - insts diff --git a/bin/triton b/bin/triton index 5c4caaf..638396a 100755 --- a/bin/triton +++ b/bin/triton @@ -1,8 +1,6 @@ #!/usr/bin/env node /* * Copyright (c) 2015 Joyent Inc. All rights reserved. - * - * triton command */ var p = console.log; diff --git a/lib/cli.js b/lib/cli.js index 74d8c16..f19bd0d 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -36,6 +36,84 @@ var log = bunyan.createLogger({ level: 'warn' }); +var OPTIONS = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Print this help and exit.' + }, + { + name: 'version', + type: 'bool', + help: 'Print version and exit.' + }, + { + names: ['verbose', 'v'], + type: 'bool', + help: 'Verbose/debug output.' + }, + + // XXX disable profile selection for now + //{names: ['profile', 'p'], type: 'string', env: 'TRITON_PROFILE', + // helpArg: 'NAME', help: 'Triton client profile to use.'} + + { + group: 'CloudAPI Options' + }, + // XXX SDC_USER support. I don't grok the node-smartdc/README.md discussion + // of SDC_USER. + { + names: ['account', 'a'], + type: 'string', + env: 'SDC_ACCOUNT', + help: 'Triton account (login name)', + helpArg: 'ACCOUNT' + }, + // XXX + //{ + // names: ['subuser', 'user'], + // type: 'string', + // env: 'MANTA_SUBUSER', + // help: 'Manta User (login name)', + // helpArg: 'USER' + //}, + //{ + // names: ['role'], + // type: 'arrayOfString', + // env: 'MANTA_ROLE', + // help: 'Assume a role. Use multiple times or once with a list', + // helpArg: 'ROLE,ROLE,...' + //}, + { + names: ['keyId', 'k'], + type: 'string', + env: 'SDC_KEY_ID', + help: 'SSH key fingerprint', + helpArg: 'FINGERPRINT' + }, + { + names: ['url', 'u'], + type: 'string', + env: 'SDC_URL', + help: 'CloudAPI URL', + helpArg: 'URL' + }, + { + names: ['J'], + type: 'string', + hidden: true, + help: 'Joyent Public Cloud (JPC) datacenter name. This is ' + + 'a shortcut to the "https://$dc.api.joyent.com" ' + + 'cloudapi URL.' + }, + { + names: ['insecure', 'i'], + type: 'bool', + help: 'Do not validate SSL certificate', + 'default': false, + env: 'SDC_TLS_INSECURE' // Deprecated SDC_TESTING supported below. + } +]; //---- CLI class @@ -44,15 +122,7 @@ function CLI() { Cmdln.call(this, { name: pkg.name, desc: pkg.description, - options: [ - {names: ['help', 'h'], type: 'bool', help: 'Print help and exit.'}, - {name: 'version', type: 'bool', help: 'Print version and exit.'}, - {names: ['verbose', 'v'], type: 'bool', - help: 'Verbose/debug output.'}, - // XXX disable profile selection for now - //{names: ['profile', 'p'], type: 'string', env: 'TRITON_PROFILE', - // helpArg: 'NAME', help: 'Triton client profile to use.'} - ], + options: OPTIONS, helpOpts: { includeEnv: true, minHelpCol: 30 @@ -102,6 +172,7 @@ CLI.prototype.init = function (opts, args, callback) { if (opts.verbose) { log.level('trace'); log.src = true; + this.showErrStack = true; } this.__defineGetter__('triton', function () { @@ -118,11 +189,29 @@ CLI.prototype.init = function (opts, args, callback) { } }); + // XXX support keyId being a priv or pub key path, a la imgapi-cli + // XXX Add TRITON_* envvars. + var envProfile = { + name: 'env', + account: opts.account, + url: opts.url, + keyId: opts.keyId, + insecure: opts.insecure + }; + if (opts.insecure === undefined && process.env.SDC_TESTING) { + opts.insecure = common.boolFromString(process.env.SDC_TESTING); + } + if (opts.J) { + envProfile.url = format('https://%s.api.joyent.com', opts.J); + } + log.trace({envProfile: envProfile}, 'envProfile'); + self._triton = new Triton({ log: log, - profile: opts.profile, - config: userConfigPath, - cachedir: cacheDir + profileName: opts.profile, + envProfile: envProfile, + configPath: userConfigPath, + cacheDir: cacheDir }); } return self._triton; diff --git a/lib/common.js b/lib/common.js index d76921b..ad0af41 100755 --- a/lib/common.js +++ b/lib/common.js @@ -197,6 +197,54 @@ function capitalize(s) { return s[0].toUpperCase() + s.substr(1); } +/* + * Normalize a short ID. Returns undefined if the given string isn't a valid + * short id. + * + * Short IDs: + * - UUID prefix + * - allow '-' to be elided (to support using containers IDs from + * docker) + * - support docker ID *longer* than a UUID? The curr implementation does. + */ +function normShortId(s) { + var shortIdCharsRe = /^[a-f0-9]+$/; + var shortId; + if (s.indexOf('-') === -1) { + if (!shortIdCharsRe.test(s)) { + return; + } + shortId = s.substr(0, 8) + '-' + + s.substr(8, 4) + '-' + + s.substr(12, 4) + '-' + + s.substr(16, 4) + '-' + + s.substr(20, 12); + shortId = shortId.replace(/-+$/, ''); + } else { + // UUID prefix. + var shortId = ''; + var chunk; + var remaining = s; + var spans = [8, 4, 4, 4, 12]; + for (var i = 0; i < spans.length; i++) { + var span = spans[i]; + head = remaining.slice(0, span); + remaining = remaining.slice(span + 1); + if (!shortIdCharsRe.test(head)) { + return; + } + shortId += head; + if (remaining) { + shortId += '-'; + } else { + break; + } + } + } + return shortId; +} + + //---- exports @@ -212,6 +260,7 @@ module.exports = { isUUID: isUUID, humanDurationFromMs: humanDurationFromMs, humanSizeFromBytes: humanSizeFromBytes, - capitalize: capitalize + capitalize: capitalize, + normShortId: normShortId }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/config.js b/lib/config.js index 336a862..c6044cc 100755 --- a/lib/config.js +++ b/lib/config.js @@ -1,13 +1,12 @@ #!/usr/bin/env node /** - * Copyright (c) 2014 Joyent Inc. All rights reserved. + * Copyright (c) 2015 Joyent Inc. All rights reserved. */ -var p = console.log; var assert = require('assert-plus'); +var format = require('util').format; var fs = require('fs'); var path = require('path'); -var sprintf = require('extsprintf').sprintf; var common = require('./common'); var errors = require('./errors'); @@ -17,6 +16,8 @@ var DEFAULT_USER_CONFIG_PATH = path.resolve(process.env.HOME, '.triton', 'config var DEFAULTS_PATH = path.resolve(__dirname, '..', 'etc', 'defaults.json'); var OVERRIDE_KEYS = []; // config object keys to do a one-level deep override + + /** * Load the Triton client config. This is a merge of the built-in "defaults" (at * etc/defaults.json) and the "user" config (at ~/.triton/config.json if it @@ -24,17 +25,21 @@ var OVERRIDE_KEYS = []; // config object keys to do a one-level deep override * * This includes some internal data on keys with a leading underscore. */ -function loadConfigSync(configPath) { +function loadConfigSync(opts) { + assert.object(opts, 'opts'); + assert.string(opts.configPath, 'opts.configPath'); + assert.optionalObject(opts.envProfile, 'opts.envProfile'); + var c = fs.readFileSync(DEFAULTS_PATH, 'utf8'); var _defaults = JSON.parse(c); var config = JSON.parse(c); - if (configPath && fs.existsSync(configPath)) { - c = fs.readFileSync(configPath, 'utf8'); + if (opts.configPath && fs.existsSync(opts.configPath)) { + c = fs.readFileSync(opts.configPath, 'utf8'); var _user = JSON.parse(c); var userConfig = JSON.parse(c); if (typeof(userConfig) !== 'object' || Array.isArray(userConfig)) { throw new errors.ConfigError( - sprintf('"%s" is not an object', configPath)); + format('"%s" is not an object', opts.configPath)); } // These special keys are merged into the key of the same name in the // base "defaults.json". @@ -56,18 +61,13 @@ function loadConfigSync(configPath) { } config._defaults = _defaults; - // Add 'env' profile. - if (!config.profiles) { - config.profiles = []; + // Add 'env' profile, if given. + if (opts.envProfile) { + if (!config.profiles) { + config.profiles = []; + } + config.profiles.push(opts.envProfile); } - //XXX Add TRITON_* envvars. - config.profiles.push({ - name: 'env', - account: process.env.SDC_USER || process.env.SDC_ACCOUNT, - url: process.env.SDC_URL, - keyId: process.env.SDC_KEY_ID, - insecure: common.boolFromString(process.env.SDC_TESTING) - }); return config; } diff --git a/lib/do_instance.js b/lib/do_instance.js index 127296b..89b4186 100644 --- a/lib/do_instance.js +++ b/lib/do_instance.js @@ -6,36 +6,25 @@ var common = require('./common'); -function do_instance(subcmd, opts, args, callback) { +function do_instance(subcmd, opts, args, cb) { if (opts.help) { - this.do_help('help', {}, [subcmd], callback); - return; + return this.do_help('help', {}, [subcmd], cb); } else if (args.length !== 1) { - callback(new Error('invalid args: ' + args)); - return; + return cb(new Error('invalid args: ' + args)); } - var id = args[0]; - - if (common.isUUID(id)) { - this.triton.cloudapi.getMachine(id, cb); - } else { - this.triton.getMachineByAlias(id, cb); - } - - function cb(err, machine) { + this.triton.getInstance(args[0], function (err, inst) { if (err) { - callback(err); - return; + return cb(err); } if (opts.json) { - console.log(JSON.stringify(machine)); + console.log(JSON.stringify(inst)); } else { - console.log(JSON.stringify(machine, null, 4)); + console.log(JSON.stringify(inst, null, 4)); } - callback(); - } + cb(); + }); } do_instance.options = [ diff --git a/lib/do_instances.js b/lib/do_instances.js index 06d2c8a..ac6947c 100644 --- a/lib/do_instances.js +++ b/lib/do_instances.js @@ -36,7 +36,8 @@ var validFields = [ 'package', 'image', 'img', - 'ago' + 'ago', + 'shortid' ]; function do_instances(subcmd, opts, args, callback) { @@ -45,8 +46,15 @@ function do_instances(subcmd, opts, args, callback) { return; } - var columns = opts.o.trim().split(','); - var sort = opts.s.trim().split(','); + var columns = 'shortid,name,state,type,img,memory,disk,ago'.split(','); + if (opts.o) { + /* JSSTYLED */ + columns = opts.o.trim().split(/\s*,\s*/g); + } else if (opts.long) { + columns[0] = 'id'; + } + /* JSSTYLED */ + var sort = opts.s.trim().split(/\s*,\s*/g); var listOpts; try { @@ -60,7 +68,7 @@ function do_instances(subcmd, opts, args, callback) { i++; var images; - this.triton.listImages({usecache: true}, function (err, _images) { + this.triton.listImages({useCache: true}, function (err, _images) { if (err) { callback(err); return; @@ -90,12 +98,15 @@ function do_instances(subcmd, opts, args, callback) { imgmap[image.id] = f('%s@%s', image.name, image.version); }); - // add extra fields for nice output + // Add extra fields for nice output. + // XXX FWIW, the "extra fields" for images and packages are not added + // for `opts.json`. Thoughts? We should be consistent there. --TM var now = new Date(); machines.forEach(function (machine) { var created = new Date(machine.created); machine.ago = common.longAgo(created, now); machine.img = imgmap[machine.image] || machine.image; + machine.shortid = machine.id.split('-', 1)[0]; }); if (opts.json) { @@ -118,6 +129,9 @@ do_instances.options = [ type: 'bool', help: 'Show this help.' }, + { + group: 'Output options' + }, { names: ['H'], type: 'bool', @@ -126,10 +140,14 @@ do_instances.options = [ { names: ['o'], type: 'string', - default: 'id,name,state,type,img,memory,disk,ago', help: 'Specify fields (columns) to output.', helpArg: 'field1,...' }, + { + names: ['long', 'l'], + type: 'bool', + help: 'Long/wider output. Ignored if "-o ..." is used.' + }, { names: ['s'], type: 'string', diff --git a/lib/do_ssh.js b/lib/do_ssh.js index 62cd623..5f97ca4 100644 --- a/lib/do_ssh.js +++ b/lib/do_ssh.js @@ -19,22 +19,15 @@ function do_ssh(subcmd, opts, args, callback) { } var id = args.shift(); - - if (common.isUUID(id)) { - this.triton.cloudapi.getMachine(id, cb); - } else { - this.triton.getMachineByAlias(id, cb); - } - - function cb(err, machine) { + this.triton.getInstance(id, function (err, inst) { if (err) { callback(err); return; } - var ip = machine.primaryIp; + var ip = inst.primaryIp; if (!ip) { - callback(new Error('primaryIp not found for machine')); + callback(new Error('primaryIp not found for instance')); return; } @@ -45,7 +38,7 @@ function do_ssh(subcmd, opts, args, callback) { child.on('close', function (code) { process.exit(code); }); - } + }); } do_ssh.options = [ diff --git a/lib/do_startstop_instance.js b/lib/do_startstop_instance.js index 0ef3da4..5a4685d 100644 --- a/lib/do_startstop_instance.js +++ b/lib/do_startstop_instance.js @@ -83,7 +83,7 @@ function _do_instance(action, subcmd, opts, args, callback) { uuid = arg; go1(); } else { - self.triton.getMachineByAlias(arg, function (err, machine) { + self.triton.getInstance(arg, function (err, machine) { if (err) { callback(err); return; diff --git a/lib/do_wait_instance.js b/lib/do_wait_instance.js index befe759..dc9e5c2 100644 --- a/lib/do_wait_instance.js +++ b/lib/do_wait_instance.js @@ -36,7 +36,7 @@ function do_wait_instance(subcmd, opts, args, cb) { return; } - self.triton.getMachineByAlias(id, function (err, machine) { + self.triton.getInstance(id, function (err, machine) { if (err) { cb(err); return; diff --git a/lib/triton.js b/lib/triton.js index bd4e1bb..b398b30 100644 --- a/lib/triton.js +++ b/lib/triton.js @@ -14,6 +14,7 @@ var once = require('once'); var path = require('path'); var restifyClients = require('restify-clients'); var tabula = require('tabula'); +var vasync = require('vasync'); var cloudapi = require('./cloudapi2'); var common = require('./common'); @@ -29,15 +30,19 @@ var loadConfigSync = require('./config').loadConfigSync; * * @param options {Object} * - log {Bunyan Logger} - * - profile {String} Optional. Name of profile to use. Defaults to + * - profileName {String} Optional. Name of profile to use. Defaults to * 'defaultProfile' in the config. + * - envProfile {Object} Optional. A starter 'env' profile object. Missing + * fields will be filled in from standard SDC_* envvars. + * ... */ function Triton(options) { assert.object(options, 'options'); assert.object(options.log, 'options.log'); - assert.optionalString(options.profile, 'options.profile'); - assert.optionalString(options.config, 'options.config'); - assert.optionalString(options.cachedir, 'options.cachedir'); + assert.optionalString(options.profileName, 'options.profileName'); + assert.optionalString(options.configPath, 'options.configPath'); + assert.optionalString(options.cacheDir, 'options.cacheDir'); + assert.optionalObject(options.envProfile, 'options.envProfile'); // Make sure a given bunyan logger has reasonable client_re[qs] serializers. // Note: This was fixed in restify, then broken again in @@ -52,12 +57,15 @@ function Triton(options) { } else { this.log = options.log; } - this.config = loadConfigSync(options.config); + this.config = loadConfigSync({ + configPath: options.configPath, + envProfile: options.envProfile + }); this.profiles = this.config.profiles; this.profile = this.getProfile( - options.profile || this.config.defaultProfile); + options.profileName || this.config.defaultProfile); this.log.trace({profile: this.profile}, 'profile data'); - this.cachedir = options.cachedir; + this.cacheDir = options.cacheDir; this.cloudapi = this._cloudapiFromProfile(this.profile); } @@ -117,23 +125,24 @@ Triton.prototype.listImages = function listImages(opts, cb) { opts = {}; } assert.object(opts, 'opts'); + assert.optionalBool(opts.useCache, 'opts.useCache'); assert.func(cb, 'cb'); - var cachefile; - if (self.cachedir) - cachefile = path.join(self.cachedir, 'images.json'); + var cacheFile; + if (self.cacheDir) + cacheFile = path.join(self.cacheDir, 'images.json'); - if (opts.usecache && !cachefile) { - cb(new Error('opts.usecache set but no cachedir found')); + if (opts.useCache && !cacheFile) { + cb(new Error('opts.useCache set but no cacheDir found')); return; } // try to read the cache if the user wants it // if this fails for any reason we fallback to hitting the cloudapi - if (opts.usecache) { - fs.readFile(cachefile, 'utf8', function (err, out) { + if (opts.useCache) { + fs.readFile(cacheFile, 'utf8', function (err, out) { if (err) { - self.log.info({err: err}, 'failed to read cache file %s', cachefile); + self.log.info({err: err}, 'failed to read cache file %s', cacheFile); fetch(); return; } @@ -141,7 +150,7 @@ Triton.prototype.listImages = function listImages(opts, cb) { try { data = JSON.parse(out); } catch (e) { - self.log.info({err: e}, 'failed to parse cache file %s', cachefile); + self.log.info({err: e}, 'failed to parse cache file %s', cacheFile); fetch(); return; } @@ -154,10 +163,10 @@ Triton.prototype.listImages = function listImages(opts, cb) { fetch(); function fetch() { self.cloudapi.listImages(opts, function (err, imgs, res) { - if (!err && self.cachedir) { + if (!err && self.cacheDir) { // cache the results var data = JSON.stringify(imgs); - fs.writeFile(cachefile, data, {encoding: 'utf8'}, function (err) { + fs.writeFile(cacheFile, data, {encoding: 'utf8'}, function (err) { if (err) self.log.info({err: err}, 'error caching images results'); done(); @@ -289,27 +298,100 @@ Triton.prototype.getPackage = function getPackage(name, cb) { /** - * getMachine for an alias + * Get an instance by ID, exact name, or short ID, in that order. * - * @param {String} alias - the machine alias - * @param {Function} callback `function (err, machine)` + * @param {String} name + * @param {Function} callback `function (err, inst)` */ -Triton.prototype.getMachineByAlias = function getMachineByAlias(alias, callback) { - this.cloudapi.listMachines({name: alias}, function (err, machines) { - if (err) { - callback(err); - return; - } - var found = false; - machines.forEach(function (machine) { - if (!found && machine.name === alias) { - callback(null, machine); - found = true; +Triton.prototype.getInstance = function getInstance(name, cb) { + var self = this; + assert.string(name, 'name'); + assert.func(cb, 'cb'); + + var shortId; + var inst; + + vasync.pipeline({funcs: [ + function tryUuid(_, next) { + var uuid; + if (common.isUUID(name)) { + uuid = name; + } else { + shortId = common.normShortId(name); + if (shortId && common.isUUID(shortId)) { + // E.g. a >32-char docker container ID normalized to a UUID. + uuid = shortId; + } else { + return next(); + } } - }); - if (!found) { - callback(new Error('machine ' + alias + ' not found')); - return; + this.cloudapi.getMachine(uuid, function (err, inst) { + inst = inst; + next(err); + }); + }, + + function tryName(_, next) { + if (inst) { + return next(); + } + + self.cloudapi.listMachines({name: name}, function (err, insts) { + if (err) { + return next(err); + } + for (var i = 0; i < insts.length; i++) { + if (insts[i].name === name) { + inst = insts[i]; + // Relying on rule that instance name is unique + // for a user and DC. + return next(); + } + } + next(); + }); + }, + + function tryShortId(_, next) { + if (inst || !shortId) { + return next(); + } + var nextOnce = once(next); + + var match; + var s = self.cloudapi.createListMachinesStream(); + s.on('error', function (err) { + nextOnce(err); + }); + s.on('readable', function () { + var inst; + while ((inst = s.read()) !== null) { + if (inst.id.slice(0, shortId.length) === shortId) { + if (match) { + return nextOnce(new Error( + 'instance short id "%s" is ambiguous', + shortId)); + } else { + match = inst; + } + } + } + }); + s.on('end', function () { + if (match) { + inst = match; + } + nextOnce(); + }); + } + ]}, function (err) { + if (err) { + cb(err); + } else if (inst) { + cb(null, inst); + } else { + cb(new Error(format( + 'no instance with name or shortId "%s" was found', name))); } }); };