shortid support for instances; --url,--account et al top-level options

This commit is contained in:
Trent Mick 2015-08-26 17:21:27 -07:00
parent 5b60fffc04
commit 9e3df02a5e
11 changed files with 327 additions and 108 deletions

View File

@ -1,5 +1,6 @@
TritonApi TritonApi
"shortid" instead of full UUID "id" in default output, and then allow lookup "shortid" instead of full UUID "id" in default output, and then allow lookup
by that shortid. Really nice for 80 columns. by that shortid. Really nice for 80 columns.
- insts - insts

View File

@ -1,8 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/* /*
* Copyright (c) 2015 Joyent Inc. All rights reserved. * Copyright (c) 2015 Joyent Inc. All rights reserved.
*
* triton command
*/ */
var p = console.log; var p = console.log;

View File

@ -36,6 +36,84 @@ var log = bunyan.createLogger({
level: 'warn' 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 //---- CLI class
@ -44,15 +122,7 @@ function CLI() {
Cmdln.call(this, { Cmdln.call(this, {
name: pkg.name, name: pkg.name,
desc: pkg.description, desc: pkg.description,
options: [ options: 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.'}
],
helpOpts: { helpOpts: {
includeEnv: true, includeEnv: true,
minHelpCol: 30 minHelpCol: 30
@ -102,6 +172,7 @@ CLI.prototype.init = function (opts, args, callback) {
if (opts.verbose) { if (opts.verbose) {
log.level('trace'); log.level('trace');
log.src = true; log.src = true;
this.showErrStack = true;
} }
this.__defineGetter__('triton', function () { 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({ self._triton = new Triton({
log: log, log: log,
profile: opts.profile, profileName: opts.profile,
config: userConfigPath, envProfile: envProfile,
cachedir: cacheDir configPath: userConfigPath,
cacheDir: cacheDir
}); });
} }
return self._triton; return self._triton;

View File

@ -197,6 +197,54 @@ function capitalize(s) {
return s[0].toUpperCase() + s.substr(1); 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 //---- exports
@ -212,6 +260,7 @@ module.exports = {
isUUID: isUUID, isUUID: isUUID,
humanDurationFromMs: humanDurationFromMs, humanDurationFromMs: humanDurationFromMs,
humanSizeFromBytes: humanSizeFromBytes, humanSizeFromBytes: humanSizeFromBytes,
capitalize: capitalize capitalize: capitalize,
normShortId: normShortId
}; };
// vim: set softtabstop=4 shiftwidth=4: // vim: set softtabstop=4 shiftwidth=4:

View File

@ -1,13 +1,12 @@
#!/usr/bin/env node #!/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 assert = require('assert-plus');
var format = require('util').format;
var fs = require('fs'); var fs = require('fs');
var path = require('path'); var path = require('path');
var sprintf = require('extsprintf').sprintf;
var common = require('./common'); var common = require('./common');
var errors = require('./errors'); 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 DEFAULTS_PATH = path.resolve(__dirname, '..', 'etc', 'defaults.json');
var OVERRIDE_KEYS = []; // config object keys to do a one-level deep override 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 * 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 * 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. * 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 c = fs.readFileSync(DEFAULTS_PATH, 'utf8');
var _defaults = JSON.parse(c); var _defaults = JSON.parse(c);
var config = JSON.parse(c); var config = JSON.parse(c);
if (configPath && fs.existsSync(configPath)) { if (opts.configPath && fs.existsSync(opts.configPath)) {
c = fs.readFileSync(configPath, 'utf8'); c = fs.readFileSync(opts.configPath, 'utf8');
var _user = JSON.parse(c); var _user = JSON.parse(c);
var userConfig = JSON.parse(c); var userConfig = JSON.parse(c);
if (typeof(userConfig) !== 'object' || Array.isArray(userConfig)) { if (typeof(userConfig) !== 'object' || Array.isArray(userConfig)) {
throw new errors.ConfigError( 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 // These special keys are merged into the key of the same name in the
// base "defaults.json". // base "defaults.json".
@ -56,18 +61,13 @@ function loadConfigSync(configPath) {
} }
config._defaults = _defaults; config._defaults = _defaults;
// Add 'env' profile. // Add 'env' profile, if given.
if (opts.envProfile) {
if (!config.profiles) { if (!config.profiles) {
config.profiles = []; config.profiles = [];
} }
//XXX Add TRITON_* envvars. config.profiles.push(opts.envProfile);
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; return config;
} }

View File

@ -6,36 +6,25 @@
var common = require('./common'); var common = require('./common');
function do_instance(subcmd, opts, args, callback) { function do_instance(subcmd, opts, args, cb) {
if (opts.help) { if (opts.help) {
this.do_help('help', {}, [subcmd], callback); return this.do_help('help', {}, [subcmd], cb);
return;
} else if (args.length !== 1) { } else if (args.length !== 1) {
callback(new Error('invalid args: ' + args)); return cb(new Error('invalid args: ' + args));
return;
} }
var id = args[0]; this.triton.getInstance(args[0], function (err, inst) {
if (common.isUUID(id)) {
this.triton.cloudapi.getMachine(id, cb);
} else {
this.triton.getMachineByAlias(id, cb);
}
function cb(err, machine) {
if (err) { if (err) {
callback(err); return cb(err);
return;
} }
if (opts.json) { if (opts.json) {
console.log(JSON.stringify(machine)); console.log(JSON.stringify(inst));
} else { } else {
console.log(JSON.stringify(machine, null, 4)); console.log(JSON.stringify(inst, null, 4));
}
callback();
} }
cb();
});
} }
do_instance.options = [ do_instance.options = [

View File

@ -36,7 +36,8 @@ var validFields = [
'package', 'package',
'image', 'image',
'img', 'img',
'ago' 'ago',
'shortid'
]; ];
function do_instances(subcmd, opts, args, callback) { function do_instances(subcmd, opts, args, callback) {
@ -45,8 +46,15 @@ function do_instances(subcmd, opts, args, callback) {
return; return;
} }
var columns = opts.o.trim().split(','); var columns = 'shortid,name,state,type,img,memory,disk,ago'.split(',');
var sort = opts.s.trim().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; var listOpts;
try { try {
@ -60,7 +68,7 @@ function do_instances(subcmd, opts, args, callback) {
i++; i++;
var images; var images;
this.triton.listImages({usecache: true}, function (err, _images) { this.triton.listImages({useCache: true}, function (err, _images) {
if (err) { if (err) {
callback(err); callback(err);
return; return;
@ -90,12 +98,15 @@ function do_instances(subcmd, opts, args, callback) {
imgmap[image.id] = f('%s@%s', image.name, image.version); 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(); var now = new Date();
machines.forEach(function (machine) { machines.forEach(function (machine) {
var created = new Date(machine.created); var created = new Date(machine.created);
machine.ago = common.longAgo(created, now); machine.ago = common.longAgo(created, now);
machine.img = imgmap[machine.image] || machine.image; machine.img = imgmap[machine.image] || machine.image;
machine.shortid = machine.id.split('-', 1)[0];
}); });
if (opts.json) { if (opts.json) {
@ -118,6 +129,9 @@ do_instances.options = [
type: 'bool', type: 'bool',
help: 'Show this help.' help: 'Show this help.'
}, },
{
group: 'Output options'
},
{ {
names: ['H'], names: ['H'],
type: 'bool', type: 'bool',
@ -126,10 +140,14 @@ do_instances.options = [
{ {
names: ['o'], names: ['o'],
type: 'string', type: 'string',
default: 'id,name,state,type,img,memory,disk,ago',
help: 'Specify fields (columns) to output.', help: 'Specify fields (columns) to output.',
helpArg: 'field1,...' helpArg: 'field1,...'
}, },
{
names: ['long', 'l'],
type: 'bool',
help: 'Long/wider output. Ignored if "-o ..." is used.'
},
{ {
names: ['s'], names: ['s'],
type: 'string', type: 'string',

View File

@ -19,22 +19,15 @@ function do_ssh(subcmd, opts, args, callback) {
} }
var id = args.shift(); var id = args.shift();
this.triton.getInstance(id, function (err, inst) {
if (common.isUUID(id)) {
this.triton.cloudapi.getMachine(id, cb);
} else {
this.triton.getMachineByAlias(id, cb);
}
function cb(err, machine) {
if (err) { if (err) {
callback(err); callback(err);
return; return;
} }
var ip = machine.primaryIp; var ip = inst.primaryIp;
if (!ip) { if (!ip) {
callback(new Error('primaryIp not found for machine')); callback(new Error('primaryIp not found for instance'));
return; return;
} }
@ -45,7 +38,7 @@ function do_ssh(subcmd, opts, args, callback) {
child.on('close', function (code) { child.on('close', function (code) {
process.exit(code); process.exit(code);
}); });
} });
} }
do_ssh.options = [ do_ssh.options = [

View File

@ -83,7 +83,7 @@ function _do_instance(action, subcmd, opts, args, callback) {
uuid = arg; uuid = arg;
go1(); go1();
} else { } else {
self.triton.getMachineByAlias(arg, function (err, machine) { self.triton.getInstance(arg, function (err, machine) {
if (err) { if (err) {
callback(err); callback(err);
return; return;

View File

@ -36,7 +36,7 @@ function do_wait_instance(subcmd, opts, args, cb) {
return; return;
} }
self.triton.getMachineByAlias(id, function (err, machine) { self.triton.getInstance(id, function (err, machine) {
if (err) { if (err) {
cb(err); cb(err);
return; return;

View File

@ -14,6 +14,7 @@ var once = require('once');
var path = require('path'); var path = require('path');
var restifyClients = require('restify-clients'); var restifyClients = require('restify-clients');
var tabula = require('tabula'); var tabula = require('tabula');
var vasync = require('vasync');
var cloudapi = require('./cloudapi2'); var cloudapi = require('./cloudapi2');
var common = require('./common'); var common = require('./common');
@ -29,15 +30,19 @@ var loadConfigSync = require('./config').loadConfigSync;
* *
* @param options {Object} * @param options {Object}
* - log {Bunyan Logger} * - 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. * '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) { function Triton(options) {
assert.object(options, 'options'); assert.object(options, 'options');
assert.object(options.log, 'options.log'); assert.object(options.log, 'options.log');
assert.optionalString(options.profile, 'options.profile'); assert.optionalString(options.profileName, 'options.profileName');
assert.optionalString(options.config, 'options.config'); assert.optionalString(options.configPath, 'options.configPath');
assert.optionalString(options.cachedir, 'options.cachedir'); assert.optionalString(options.cacheDir, 'options.cacheDir');
assert.optionalObject(options.envProfile, 'options.envProfile');
// Make sure a given bunyan logger has reasonable client_re[qs] serializers. // Make sure a given bunyan logger has reasonable client_re[qs] serializers.
// Note: This was fixed in restify, then broken again in // Note: This was fixed in restify, then broken again in
@ -52,12 +57,15 @@ function Triton(options) {
} else { } else {
this.log = options.log; this.log = options.log;
} }
this.config = loadConfigSync(options.config); this.config = loadConfigSync({
configPath: options.configPath,
envProfile: options.envProfile
});
this.profiles = this.config.profiles; this.profiles = this.config.profiles;
this.profile = this.getProfile( this.profile = this.getProfile(
options.profile || this.config.defaultProfile); options.profileName || this.config.defaultProfile);
this.log.trace({profile: this.profile}, 'profile data'); this.log.trace({profile: this.profile}, 'profile data');
this.cachedir = options.cachedir; this.cacheDir = options.cacheDir;
this.cloudapi = this._cloudapiFromProfile(this.profile); this.cloudapi = this._cloudapiFromProfile(this.profile);
} }
@ -117,23 +125,24 @@ Triton.prototype.listImages = function listImages(opts, cb) {
opts = {}; opts = {};
} }
assert.object(opts, 'opts'); assert.object(opts, 'opts');
assert.optionalBool(opts.useCache, 'opts.useCache');
assert.func(cb, 'cb'); assert.func(cb, 'cb');
var cachefile; var cacheFile;
if (self.cachedir) if (self.cacheDir)
cachefile = path.join(self.cachedir, 'images.json'); cacheFile = path.join(self.cacheDir, 'images.json');
if (opts.usecache && !cachefile) { if (opts.useCache && !cacheFile) {
cb(new Error('opts.usecache set but no cachedir found')); cb(new Error('opts.useCache set but no cacheDir found'));
return; return;
} }
// try to read the cache if the user wants it // try to read the cache if the user wants it
// if this fails for any reason we fallback to hitting the cloudapi // if this fails for any reason we fallback to hitting the cloudapi
if (opts.usecache) { if (opts.useCache) {
fs.readFile(cachefile, 'utf8', function (err, out) { fs.readFile(cacheFile, 'utf8', function (err, out) {
if (err) { 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(); fetch();
return; return;
} }
@ -141,7 +150,7 @@ Triton.prototype.listImages = function listImages(opts, cb) {
try { try {
data = JSON.parse(out); data = JSON.parse(out);
} catch (e) { } 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(); fetch();
return; return;
} }
@ -154,10 +163,10 @@ Triton.prototype.listImages = function listImages(opts, cb) {
fetch(); fetch();
function fetch() { function fetch() {
self.cloudapi.listImages(opts, function (err, imgs, res) { self.cloudapi.listImages(opts, function (err, imgs, res) {
if (!err && self.cachedir) { if (!err && self.cacheDir) {
// cache the results // cache the results
var data = JSON.stringify(imgs); var data = JSON.stringify(imgs);
fs.writeFile(cachefile, data, {encoding: 'utf8'}, function (err) { fs.writeFile(cacheFile, data, {encoding: 'utf8'}, function (err) {
if (err) if (err)
self.log.info({err: err}, 'error caching images results'); self.log.info({err: err}, 'error caching images results');
done(); 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 {String} name
* @param {Function} callback `function (err, machine)` * @param {Function} callback `function (err, inst)`
*/ */
Triton.prototype.getMachineByAlias = function getMachineByAlias(alias, callback) { Triton.prototype.getInstance = function getInstance(name, cb) {
this.cloudapi.listMachines({name: alias}, function (err, machines) { var self = this;
if (err) { assert.string(name, 'name');
callback(err); assert.func(cb, 'cb');
return;
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();
}
}
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;
}
} }
var found = false;
machines.forEach(function (machine) {
if (!found && machine.name === alias) {
callback(null, machine);
found = true;
} }
}); });
if (!found) { s.on('end', function () {
callback(new Error('machine ' + alias + ' not found')); if (match) {
return; 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)));
} }
}); });
}; };