This repository has been archived on 2020-01-20. You can view files and clone it, but cannot push or open issues or pull requests.
node-spearhead/lib/cli.js

738 lines
24 KiB
JavaScript
Raw Normal View History

2014-02-07 23:21:24 +02:00
/*
2015-09-04 21:12:20 +03:00
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Copyright 2016 Joyent, Inc.
2014-02-07 23:21:24 +02:00
*
* The `triton` CLI class.
2014-02-07 23:21:24 +02:00
*/
var assert = require('assert-plus');
var bunyan = require('bunyan');
2014-02-07 23:21:24 +02:00
var child_process = require('child_process'),
spawn = child_process.spawn,
exec = child_process.exec;
var cmdln = require('cmdln'),
Cmdln = cmdln.Cmdln;
var fs = require('fs');
2015-09-04 01:12:08 +03:00
var mkdirp = require('mkdirp');
var util = require('util'),
format = util.format;
2015-08-26 19:59:12 +03:00
var path = require('path');
var vasync = require('vasync');
2014-02-07 23:21:24 +02:00
var common = require('./common');
var mod_config = require('./config');
2014-02-08 10:15:26 +02:00
var errors = require('./errors');
var tritonapi = require('./tritonapi');
2014-02-07 23:21:24 +02:00
//---- globals
var packageJson = require('../package.json');
var CONFIG_DIR;
if (process.platform === 'win32') {
/*
* For better or worse we are using APPDATA (i.e. the *Roaming* AppData
* dir) over LOCALAPPDATA (non-roaming). The former is meant for "user"
* data, the latter for "machine" data.
*
* TODO: We should likely separate out the *cache* subdir to
* machine-specific data dir.
*/
CONFIG_DIR = path.resolve(process.env.APPDATA, 'Joyent', 'Triton');
} else {
CONFIG_DIR = path.resolve(process.env.HOME, '.triton');
}
2014-02-07 23:21:24 +02:00
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.'
},
{
names: ['profile', 'p'],
type: 'string',
completionType: 'tritonprofile',
env: 'TRITON_PROFILE',
helpArg: 'NAME',
help: 'Triton client profile to use.'
},
{
group: 'CloudAPI Options'
},
/*
* Environment variable integration.
*
* While dashdash supports integrated envvar parsing with options
* we don't use that with `triton` because (a) we want to apply *option*
* usage (but not envvars) to profiles other than the default 'env'
* profile, and (b) we want to support `TRITON_*` *and* `SDC_*` envvars,
* which dashdash doesn't support.
*
* See <https://github.com/joyent/node-triton/issues/28> for some details.
*/
{
names: ['account', 'a'],
type: 'string',
help: 'Account (login name). Environment: TRITON_ACCOUNT=ACCOUNT ' +
'or SDC_ACCOUNT=ACCOUNT.',
helpArg: 'ACCOUNT'
},
{
names: ['act-as'],
type: 'string',
help: 'Masquerade as the given account login name. This can only ' +
'succeed for operator accounts. Note that accesses like these ' +
'audited on the CloudAPI server side.',
helpArg: 'ACCOUNT',
hidden: true
},
{
names: ['user', 'u'],
type: 'string',
help: 'RBAC user (login name). Environment: TRITON_USER=USER ' +
'or SDC_USER=USER.',
helpArg: 'USER'
},
// TODO: full rbac support
//{
// 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',
2015-09-25 20:22:58 +03:00
help: 'SSH key fingerprint. Environment: TRITON_KEY_ID=FINGERPRINT ' +
'or SDC_KEY_ID=FINGERPRINT.',
helpArg: 'FP'
},
{
names: ['url', 'U'],
type: 'string',
help: 'CloudAPI URL. Environment: TRITON_URL=URL or SDC_URL=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 the CloudAPI SSL certificate. Environment: ' +
'TRITON_TLS_INSECURE=1, SDC_TLS_INSECURE=1 (or the deprecated ' +
'SDC_TESTING=1).',
2015-09-21 22:37:59 +03:00
'default': false
},
{
names: ['accept-version'],
type: 'string',
helpArg: 'VER',
help: 'A cloudapi API version, or semver range, to attempt to use. ' +
'This is passed in the "Accept-Version" header. ' +
'See `triton cloudapi /--ping` to list supported versions. ' +
'The default is "' + tritonapi.CLOUDAPI_ACCEPT_VERSION + '". ' +
'*This is intended for development use only. It could cause ' +
'`triton` processing of responses to break.*',
hidden: true
}
];
2014-02-07 23:21:24 +02:00
// ---- other support stuff
function parseCommaSepStringNoEmpties(option, optstr, arg) {
// JSSTYLED
return arg.trim().split(/\s*,\s*/g)
.filter(function (part) { return part; });
}
cmdln.dashdash.addOptionType({
name: 'commaSepString',
takesArg: true,
helpArg: 'STRING',
parseArg: parseCommaSepStringNoEmpties
});
cmdln.dashdash.addOptionType({
name: 'arrayOfCommaSepString',
takesArg: true,
helpArg: 'STRING',
parseArg: parseCommaSepStringNoEmpties,
array: true,
arrayFlatten: true
});
2014-02-07 23:21:24 +02:00
//---- CLI class
function CLI() {
Cmdln.call(this, {
name: 'triton',
desc: packageJson.description,
options: OPTIONS,
2014-02-07 23:21:24 +02:00
helpOpts: {
includeEnv: true,
2015-08-26 07:34:47 +03:00
minHelpCol: 30
},
helpSubcmds: [
'help',
'profile',
'env',
'completion',
2015-08-26 01:15:02 +03:00
{ group: 'Instances (aka VMs/Machines/Containers)' },
2015-08-26 03:27:46 +03:00
'instance',
'instances',
'create',
'delete',
'start',
'stop',
'reboot',
2015-08-26 06:25:00 +03:00
'ssh',
'ip',
{ group: 'Images, Packages, Networks, Firewall Rules' },
2015-08-26 01:47:29 +03:00
'image',
2015-08-26 23:40:50 +03:00
'package',
2015-12-30 02:47:03 +02:00
'network',
'fwrule',
2015-12-30 02:47:03 +02:00
{ group: 'Other Commands' },
'info',
'account',
'key',
2015-12-30 02:47:03 +02:00
'services',
'datacenters'
],
helpBody: [
/* BEGIN JSSTYLED */
'Exit Status:',
' 0 Successful completion.',
' 1 An error occurred.',
' 2 Usage error.',
' 3 "ResourceNotFound" error (when an instance, image, etc. with',
' the given name or id is not found) or "InstanceDeleted" error.'
/* END JSSTYLED */
].join('\n')
2014-02-07 23:21:24 +02:00
});
}
util.inherits(CLI, Cmdln);
CLI.prototype.init = function (opts, args, callback) {
var self = this;
this.opts = opts;
2014-02-07 23:21:24 +02:00
this.log = bunyan.createLogger({
name: this.name,
serializers: bunyan.stdSerializers,
stream: process.stderr,
level: 'warn'
});
2014-02-07 23:21:24 +02:00
if (opts.verbose) {
this.log.level('trace');
this.log.src = true;
this.showErrStack = true;
2014-02-07 23:21:24 +02:00
}
if (opts.version) {
console.log(this.name, packageJson.version);
callback(false);
return;
}
if (opts.url && opts.J) {
callback(new errors.UsageError(
'cannot use both "--url" and "-J" options'));
} else if (opts.J) {
opts.url = format('https://%s.api.joyent.com', opts.J);
}
this.configDir = CONFIG_DIR;
2015-08-26 19:59:12 +03:00
this.__defineGetter__('config', function getConfig() {
if (self._config === undefined) {
self._config = mod_config.loadConfig({
configDir: self.configDir
});
self.log.trace({config: self._config}, 'loaded config');
}
return self._config;
});
this.__defineGetter__('profileName', function getProfileName() {
return (opts.profile || self.config.profile || 'env');
});
this.__defineGetter__('profile', function getProfile() {
if (self._profile === undefined) {
self._profile = mod_config.loadProfile({
configDir: self.configDir,
name: self.profileName
});
self._applyProfileOverrides(self._profile);
self.log.trace({profile: self._profile}, 'loaded profile');
}
return self._profile;
});
this.__defineGetter__('tritonapi', function getTritonapi() {
if (self._tritonapi === undefined) {
self._tritonapi = tritonapi.createClient({
log: self.log,
profile: self.profile,
config: self.config
2015-08-26 19:59:12 +03:00
});
}
return self._tritonapi;
});
2014-02-07 23:21:24 +02:00
if (process.env.TRITON_COMPLETE) {
/*
* If `TRITON_COMPLETE=<type>` is set (typically only in the
* Triton CLI bash completion driver, see
* "etc/triton-bash-completion-types.sh"), then Bash completions are
* fetched and printed, instead of the usual subcommand handling.
*
* Completion results are typically cached (under "~/.triton/cache")
* to avoid hitting the server for data everytime.
*
* Example usage:
* TRITON_COMPLETE=images triton -p my-profile create
*/
this._emitCompletions(process.env.TRITON_COMPLETE, function (err) {
callback(err || false);
});
} else {
// Cmdln class handles `opts.help`.
Cmdln.prototype.init.apply(this, arguments);
}
2014-02-07 23:21:24 +02:00
};
CLI.prototype.fini = function fini(subcmd, err, cb) {
this.log.trace({err: err, subcmd: subcmd}, 'cli fini');
if (this._tritonapi) {
this._tritonapi.close();
delete this._tritonapi;
}
cb(err, subcmd);
};
/*
* Fetch and display Bash completions (one completion per line) for the given
* Triton data type (e.g. 'images', 'instances', 'packages', ...).
* This caches results (per profile) with a 5 minute TTL.
*
* Dev Note: If the cache path logic changes, then the *Bash* implementation
* of the same logic in "etc/triton-bash-completion-types.sh" must be updated
* to match.
*/
CLI.prototype._emitCompletions = function _emitCompletions(type, cb) {
assert.string(type, 'type');
assert.func(cb, 'cb');
var cacheFile = path.join(this.tritonapi.cacheDir, type + '.completions');
var ttl = 5 * 60 * 1000; // timeout of cache file info (ms)
var cloudapi = this.tritonapi.cloudapi;
vasync.pipeline({arg: {}, funcs: [
function tryCacheFile(arg, next) {
fs.stat(cacheFile, function (err, stats) {
if (!err &&
stats.mtime.getTime() + ttl >= (new Date()).getTime()) {
process.stdout.write(fs.readFileSync(cacheFile));
next(true); // early abort
} else if (err && err.code !== 'ENOENT') {
next(err);
} else {
next();
}
});
},
function gather(arg, next) {
var completions;
switch (type) {
case 'packages':
cloudapi.listPackages({}, function (err, pkgs) {
if (err) {
next(err);
return;
}
completions = [];
pkgs.forEach(function (pkg) {
if (pkg.name.indexOf(' ') === -1) {
// Cannot bash complete results with spaces, so
// skip them here.
completions.push(pkg.name);
}
completions.push(pkg.id);
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
case 'images':
cloudapi.listImages({}, function (err, imgs) {
if (err) {
next(err);
return;
}
completions = [];
imgs.forEach(function (img) {
// Cannot bash complete results with spaces, so
// skip them here.
if (img.name.indexOf(' ') === -1) {
completions.push(img.name);
if (img.version.indexOf(' ') === -1) {
completions.push(img.name + '@' + img.version);
}
}
completions.push(img.id);
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
case 'instances':
cloudapi.listMachines({}, function (err, insts) {
if (err) {
next(err);
return;
}
completions = [];
insts.forEach(function (inst) {
if (inst.name.indexOf(' ') === -1) {
// Cannot bash complete results with spaces, so
// skip them here.
completions.push(inst.name);
}
completions.push(inst.id);
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
case 'affinityrules':
/*
* We exclude ids, in favour of just inst names here. The only
* justification for differing from other completion types
* on that is that with the additional prefixes, there would
* be too many.
*/
cloudapi.listMachines({}, function (err, insts) {
if (err) {
next(err);
return;
}
completions = [];
insts.forEach(function (inst) {
if (inst.name.indexOf(' ') === -1) {
// Cannot bash complete results with spaces, so
// skip them here.
completions.push('inst==' + inst.name);
completions.push('inst!=' + inst.name);
completions.push('inst==~' + inst.name);
completions.push('inst!=~' + inst.name);
}
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
case 'networks':
cloudapi.listNetworks({}, function (err, nets) {
if (err) {
next(err);
return;
}
completions = [];
nets.forEach(function (net) {
if (net.name.indexOf(' ') === -1) {
// Cannot bash complete results with spaces, so
// skip them here.
completions.push(net.name);
}
completions.push(net.id);
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
case 'fwrules':
cloudapi.listFirewallRules({}, function (err, fwrules) {
if (err) {
next(err);
return;
}
completions = [];
fwrules.forEach(function (fwrule) {
completions.push(fwrule.id);
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
case 'keys':
cloudapi.listKeys({}, function (err, keys) {
if (err) {
next(err);
return;
}
completions = [];
keys.forEach(function (key) {
if (key.name.indexOf(' ') === -1) {
// Cannot bash complete results with spaces, so
// skip them here.
completions.push(key.name);
}
completions.push(key.fingerprint);
});
arg.completions = completions.join('\n') + '\n';
next();
});
break;
default:
process.stderr.write('warning: unknown triton completion type: '
+ type + '\n');
next();
break;
}
},
function saveCache(arg, next) {
if (!arg.completions) {
next();
return;
}
fs.writeFile(cacheFile, arg.completions, next);
},
function emit(arg, next) {
if (arg.completions) {
console.log(arg.completions);
}
next();
}
]}, function (err) {
if (err === true) { // early abort signal
err = null;
}
cb(err);
});
};
/*
* Apply overrides from CLI options to the given profile object *in place*.
*/
CLI.prototype._applyProfileOverrides =
function _applyProfileOverrides(profile) {
var self = this;
[
{oname: 'account', pname: 'account'},
{oname: 'user', pname: 'user'},
{oname: 'url', pname: 'url'},
{oname: 'keyId', pname: 'keyId'},
{oname: 'insecure', pname: 'insecure'},
{oname: 'accept_version', pname: 'acceptVersion'},
{oname: 'act_as', pname: 'actAsAccount'}
].forEach(function (field) {
// We need to check `opts._order` to know if boolean opts
// were specified.
var specified = self.opts._order.filter(
function (opt) { return opt.key === field.oname; }).length > 0;
if (specified) {
profile[field.pname] = self.opts[field.oname];
}
});
};
/*
* Create and return a TritonApi instance for the given profile name and using
* the CLI's config. Callers of this should remember to `tritonapi.close()`
* when complete... otherwise an HTTP Agent using keep-alive will keep node
* from exiting until it times out.
*/
CLI.prototype.tritonapiFromProfileName =
function tritonapiFromProfileName(opts) {
assert.object(opts, 'opts');
assert.string(opts.profileName, 'opts.profileName');
var profile;
if (opts.profileName === this.profileName) {
profile = this.profile;
} else {
profile = mod_config.loadProfile({
configDir: this.configDir,
name: opts.profileName
});
this.log.trace({profile: profile},
'tritonapiFromProfileName: loaded profile');
}
return tritonapi.createClient({
log: this.log,
profile: profile,
config: this.config
});
};
2015-09-04 10:09:19 +03:00
// Meta
CLI.prototype.do_completion = require('./do_completion');
CLI.prototype.do_profiles = require('./do_profiles');
CLI.prototype.do_profile = require('./do_profile');
2015-11-25 02:40:17 +02:00
CLI.prototype.do_env = require('./do_env');
2014-02-07 23:21:24 +02:00
2015-09-04 10:09:19 +03:00
// Other
CLI.prototype.do_account = require('./do_account');
2015-08-27 02:56:18 +03:00
CLI.prototype.do_services = require('./do_services');
2015-08-27 02:59:28 +03:00
CLI.prototype.do_datacenters = require('./do_datacenters');
2015-08-26 07:16:41 +03:00
CLI.prototype.do_info = require('./do_info');
// Account keys
CLI.prototype.do_key = require('./do_key');
2015-08-26 07:40:32 +03:00
CLI.prototype.do_keys = require('./do_keys');
// Firewall rules
CLI.prototype.do_fwrule = require('./do_fwrule');
// Images
2015-08-26 00:25:30 +03:00
CLI.prototype.do_images = require('./do_images');
2015-08-26 01:47:29 +03:00
CLI.prototype.do_image = require('./do_image');
2015-08-26 00:25:30 +03:00
2015-08-26 01:15:02 +03:00
// Instances (aka VMs/containers/machines)
2015-08-26 03:27:46 +03:00
CLI.prototype.do_instance = require('./do_instance');
2015-08-26 01:15:02 +03:00
CLI.prototype.do_instances = require('./do_instances');
CLI.prototype.do_create = require('./do_create');
CLI.prototype.do_delete = require('./do_delete');
CLI.prototype.do_start = require('./do_start');
CLI.prototype.do_stop = require('./do_stop');
CLI.prototype.do_reboot = require('./do_reboot');
2015-08-26 06:25:00 +03:00
CLI.prototype.do_ssh = require('./do_ssh');
CLI.prototype.do_ip = require('./do_ip');
2014-02-07 23:21:24 +02:00
// Packages
CLI.prototype.do_packages = require('./do_packages');
CLI.prototype.do_package = require('./do_package');
2015-08-26 23:40:50 +03:00
// Networks
CLI.prototype.do_networks = require('./do_networks');
2015-08-27 00:09:50 +03:00
CLI.prototype.do_network = require('./do_network');
2015-08-26 23:40:50 +03:00
// Hidden commands
CLI.prototype.do_cloudapi = require('./do_cloudapi');
CLI.prototype.do_badger = require('./do_badger');
CLI.prototype.do_rbac = require('./do_rbac');
2014-02-07 23:21:24 +02:00
2015-07-26 08:45:20 +03:00
//---- mainline
function main(argv) {
if (!argv) {
argv = process.argv;
}
var cli = new CLI();
cli.main(argv, function (err, subcmd) {
var exitStatus = (err ? err.exitStatus || 1 : 0);
var showErr = (cli.showErr !== undefined ? cli.showErr : true);
if (err && showErr) {
var code = (err.body ? err.body.code : err.code) || err.restCode;
if (code === 'NoCommand') {
/* jsl:pass */
} else if (err.message !== undefined) {
/*
* If the err has `body.errors` (as some Triton/SDC APIs do per
* // JSSTYLED
* https://github.com/joyent/eng/blob/master/docs/index.md#error-handling
* then append a one-line summary for each error object.
*/
var bodyErrors = '';
if (err.body && err.body.errors) {
err.body.errors.forEach(function (e) {
bodyErrors += format('\n %s: %s', e.field, e.code);
if (e.message) {
bodyErrors += ': ' + e.message;
}
});
}
console.error('%s%s: error%s: %s%s',
cli.name,
(subcmd ? ' ' + subcmd : ''),
(code ? format(' (%s)', code) : ''),
(cli.showErrStack ? err.stack : err.message),
bodyErrors);
// If this is a usage error, attempt to show some usage info.
if (['Usage', 'Option'].indexOf(code) !== -1 && subcmd) {
var help = cli.helpFromSubcmd(subcmd);
if (help && typeof (help) === 'string') {
// Would like a shorter synopsis. Attempt to
// parse it down, somewhat generally. Unfortunately this
// doesn't work for multi-level subcmds, like
// `triton rbac subcmd ...`.
var usageIdx = help.indexOf('\nUsage:');
if (usageIdx !== -1) {
help = help.slice(usageIdx);
}
console.error(help);
}
}
}
}
/*
* We'd like to NOT use `process.exit` because that doesn't always
* allow std handles to flush (e.g. all logging to complete). However
* I don't know of another way to exit non-zero.
*/
if (exitStatus !== 0) {
process.exit(exitStatus);
}
});
}
2014-02-07 23:21:24 +02:00
//---- exports
module.exports = {
CONFIG_DIR: CONFIG_DIR,
CLI: CLI,
main: main
};