joyent/node-triton#101 Bash completion for server-side data: instances, images, etc.

This commit is contained in:
Trent Mick 2016-03-09 09:19:44 -08:00
parent 7d635fc81c
commit e3b5e6b016
42 changed files with 447 additions and 17 deletions

View File

@ -1,7 +1,14 @@
# node-triton changelog
## 4.6.1 (not yet released)
## 4.7.0 (not yet released)
- #101 Bash completion for server-side data: instances, images, etc.
Bash completion on TAB should now work for things like the following:
`triton create <TAB to complete images> <TAB to complete packages`,
`triton inst tag ls <TAB to complete instances>`. Cached (with a 5 minute
TTL) completions for the following data are supported: instances, images,
packages, networks, fwrules, account keys.
See `triton completion --help` for adding/updating Bash completion.
- #99 `triton profile set ...` alias for `set-current`

View File

@ -1,4 +1,5 @@
# Functions for Bash completion of some 'triton' option/arg types.
function complete_tritonprofile {
local word="$1"
local candidates
@ -7,6 +8,144 @@ function complete_tritonprofile {
compgen $compgen_opts -W "$candidates" -- "$word"
}
#
# Get completions for a given type of Triton (server-side) data.
#
# Usage:
# _complete_tritondata $type # e.g. _complete_tritondata images
#
# The easiest/slowest thing to do to complete images would be to just call:
# triton [profile-related-args] images -Ho name
# or similar. Too slow.
#
# The next easiest would be this:
# candidates=$(TRITON_COMPLETE=$type $COMP_LINE)
# where `triton` is setup to specially just handle completions if
# `TRITON_COMPLETE` is set. That special handling writes out a cache file to
# avoid hitting the server every time. This is still too slow because the
# node.js startup time for `triton` is too slow (around 1s on my laptop).
#
# The next choice is to (a) use the special `TRITON_COMPLETE` handling to
# fetch data from the server and write out a cache file, but (b) attempt to
# find and use that cache file without calling node.js code. The win is
# (at least in my usage) faster response time to a <TAB>. The cost is doing
# reproducing (imperfectly) in Bash the logic for determining the Triton profile
# info to find the cache.
#
function _complete_tritondata {
local type=$1
# First, find the Triton CLI profile.
local profile
profile=$(echo "$COMP_LINE" | grep -- '\s\+-p\s*\w\+\s\+' | sed -E 's/.* +-p *([^ ]+) +.*/\1/')
if [[ -z "$profile" ]]; then
profile=$TRITON_PROFILE
fi
if [[ -z "$profile" ]]; then
profile=$(grep '"profile":' ~/.triton/config.json | cut -d'"' -f4)
fi
if [[ -z "$profile" ]]; then
profile=env
fi
trace " profile: $profile"
# Then, determine the account and url that go into the cache dir.
# TODO: include -a/-U options that change from profile values
# TODO: subuser support
local url
local account
local profileFile
profileFile=$HOME/.triton/profiles.d/$profile.json
if [[ "$profile" == "env" ]]; then
url=$TRITON_URL
if [[ -z "$url" ]]; then
url=$SDC_URL
fi
account=$TRITON_ACCOUNT
if [[ -z "$account" ]]; then
account=$SDC_ACCOUNT
fi
elif [[ -f $profileFile ]]; then
url=$(grep '"url":' $profileFile | cut -d'"' -f4)
account=$(grep '"account":' $profileFile | cut -d'"' -f4)
fi
trace " url: $url"
trace " account: $account"
# Mimic node-triton/lib/common.js#profileSlug
local profileSlug
profileSlug="$(echo "$account" | sed -E 's/@/_/g')@$(echo "$url" | sed -E 's#^https?://##')"
profileSlug="$(echo "$profileSlug" | sed -E 's/[^a-zA-Z0-9_@-]/_/g')"
local cacheFile
cacheFile="$HOME/.triton/cache/$profileSlug/$type.completions"
trace " cacheFile: $cacheFile"
# If we have a cache file, remove it and regenerate if it is >5 minutes old.
#
# Dev Note: This 5min TTL should match what `lib/cli.js#_emitCompletions()`
# is using.
local candidates
if [[ ! -f "$cacheFile" ]]; then
candidates=$(TRITON_COMPLETE=$type $COMP_LINE)
else
local mtime
mtime=$(stat -r "$cacheFile" | awk '{print $10}')
local ttl=300 # 5 minutes in seconds
local age
age=$(echo "$(date +%s) - $mtime" | bc)
if [[ $age -gt $ttl ]]; then
# Out of date. Regenerate the cache file.
trace " cacheFile out-of-date (mtime=$mtime, age=$age, ttl=$ttl)"
rm "$cacheFile"
candidates=$(TRITON_COMPLETE=$type $COMP_LINE)
else
trace " cacheFile is in-date (mtime=$mtime, age=$age, ttl=$ttl)"
candidates=$(cat "$cacheFile")
fi
fi
echo "$candidates"
}
function complete_tritonpackage {
local word="$1"
candidates=$(_complete_tritondata packages)
compgen $compgen_opts -W "$candidates" -- "$word"
}
function complete_tritonimage {
local word="$1"
candidates=$(_complete_tritondata images)
compgen $compgen_opts -W "$candidates" -- "$word"
}
function complete_tritoninstance {
local word="$1"
candidates=$(_complete_tritondata instances)
compgen $compgen_opts -W "$candidates" -- "$word"
}
function complete_tritonnetwork {
local word="$1"
candidates=$(_complete_tritondata networks)
compgen $compgen_opts -W "$candidates" -- "$word"
}
function complete_tritonfwrule {
local word="$1"
candidates=$(_complete_tritondata fwrules)
compgen $compgen_opts -W "$candidates" -- "$word"
}
function complete_tritonkey {
local word="$1"
candidates=$(_complete_tritondata keys)
compgen $compgen_opts -W "$candidates" -- "$word"
}
function complete_tritonupdateaccountfield {
local word="$1"
local candidates

View File

@ -17,6 +17,7 @@ var child_process = require('child_process'),
exec = child_process.exec;
var cmdln = require('cmdln'),
Cmdln = cmdln.Cmdln;
var fs = require('fs');
var mkdirp = require('mkdirp');
var util = require('util'),
format = util.format;
@ -32,7 +33,7 @@ var tritonapi = require('./tritonapi');
//---- globals
var pkg = require('../package.json');
var packageJson = require('../package.json');
var CONFIG_DIR;
if (process.platform === 'win32') {
@ -198,7 +199,7 @@ cmdln.dashdash.addOptionType({
function CLI() {
Cmdln.call(this, {
name: 'triton',
desc: pkg.description,
desc: packageJson.description,
options: OPTIONS,
helpOpts: {
includeEnv: true,
@ -260,7 +261,7 @@ CLI.prototype.init = function (opts, args, callback) {
}
if (opts.version) {
console.log(this.name, pkg.version);
console.log(this.name, packageJson.version);
callback(false);
return;
}
@ -298,8 +299,26 @@ CLI.prototype.init = function (opts, args, callback) {
return self._tritonapi;
});
// Cmdln class handles `opts.help`.
Cmdln.prototype.init.apply(this, arguments);
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);
}
};
@ -313,6 +332,185 @@ CLI.prototype.fini = function fini(subcmd, err, cb) {
};
/*
* 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 '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*.
*/

View File

@ -10,6 +10,8 @@
* `triton create ...` bwcompat shortcut for `triton instance create ...`.
*/
var targ = require('./do_instance/do_create');
function do_create(subcmd, opts, args, callback) {
this.handlerFromSubcmd('instance').dispatch({
subcmd: 'create',
@ -19,6 +21,8 @@ function do_create(subcmd, opts, args, callback) {
}
do_create.help = 'A shortcut for "triton instance create".';
do_create.options = require('./do_instance/do_create').options;
do_create.options = targ.options;
do_create.completionArgtypes = targ.completionArgtypes;
module.exports = do_create;

View File

@ -10,6 +10,8 @@
* `triton delete ...` bwcompat shortcut for `triton instance delete ...`.
*/
var targ = require('./do_instance/do_delete');
function do_delete(subcmd, opts, args, callback) {
this.handlerFromSubcmd('instance').dispatch({
subcmd: 'delete',
@ -20,6 +22,7 @@ function do_delete(subcmd, opts, args, callback) {
do_delete.help = 'A shortcut for "triton instance delete".';
do_delete.aliases = ['rm'];
do_delete.options = require('./do_instance/do_delete').options;
do_delete.options = targ.options;
do_delete.completionArgtypes = targ.completionArgtypes;
module.exports = do_delete;

View File

@ -107,4 +107,6 @@ do_delete.help = [
do_delete.aliases = ['rm'];
do_delete.completionArgtypes = ['tritonfwrule'];
module.exports = do_delete;

View File

@ -65,4 +65,6 @@ do_disable.help = [
'{{options}}'
].join('\n');
do_disable.completionArgtypes = ['tritonfwrule'];
module.exports = do_disable;

View File

@ -65,4 +65,6 @@ do_enable.help = [
'{{options}}'
].join('\n');
do_enable.completionArgtypes = ['tritonfwrule'];
module.exports = do_enable;

View File

@ -73,4 +73,6 @@ do_get.help = [
'{{options}}'
].join('\n');
do_get.completionArgtypes = ['tritonfwrule', 'none'];
module.exports = do_get;

View File

@ -159,4 +159,6 @@ do_instances.help = [
do_instances.aliases = ['insts'];
do_instances.completionArgtypes = ['tritonfwrule', 'none'];
module.exports = do_instances;

View File

@ -265,5 +265,6 @@ do_create.helpOpts = {
maxHelpCol: 20
};
do_create.completionArgtypes = ['tritoninstance', 'file'];
module.exports = do_create;

View File

@ -151,5 +151,7 @@ do_delete.options = [
}
];
do_delete.completionArgtypes = ['tritonimage'];
do_delete.aliases = ['rm'];
module.exports = do_delete;

View File

@ -67,4 +67,6 @@ do_get.help = (
/* END JSSTYLED */
);
do_get.completionArgtypes = ['tritonimage', 'none'];
module.exports = do_get;

View File

@ -116,13 +116,14 @@ do_wait.help = [
'Wait for images to change to a particular state.',
'',
'Usage:',
' {{name}} wait [-s STATES] IMAGE [IMAGE ...]',
' {{name}} wait [-s STATES] IMAGE [IMAGE ...]',
'',
'{{options}}',
'Where "states" is a comma-separated list of target instance states,',
'by default "active,failed". In other words, "triton img wait foo0" will',
'wait for image "foo0" to complete creation.'
].join('\n');
do_wait.options = [
{
names: ['help', 'h'],
@ -139,4 +140,6 @@ do_wait.options = [
}
];
do_wait.completionArgtypes = ['tritonimage'];
module.exports = do_wait;

View File

@ -111,4 +111,6 @@ do_audit.help = (
+ '{{options}}'
);
do_audit.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_audit;

View File

@ -5,7 +5,7 @@
*/
/*
* Copyright 2015 Joyent, Inc.
* Copyright 2016 Joyent, Inc.
*
* `triton instance create ...`
*/
@ -289,7 +289,8 @@ do_create.options = [
type: 'arrayOfCommaSepString',
helpArg: 'NETWORK',
help: 'One or more comma-separated networks (ID, name or short id). ' +
'This option can be used multiple times.'
'This option can be used multiple times.',
completionType: 'tritonnetwork'
},
// XXX locality: near, far
@ -330,5 +331,6 @@ do_create.helpOpts = {
maxHelpCol: 18
};
do_create.completionArgtypes = ['tritonimage', 'tritonpackage', 'none'];
module.exports = do_create;

View File

@ -100,4 +100,6 @@ do_fwrules.help = [
'{{options}}'
].join('\n');
do_fwrules.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_fwrules;

View File

@ -60,4 +60,6 @@ do_get.help = (
/* END JSSTYLED */
);
do_get.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_get;

View File

@ -133,4 +133,6 @@ do_create.help = [
'Snapshot do not work for instances of type "kvm".'
].join('\n');
do_create.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_create;

View File

@ -150,4 +150,8 @@ do_delete.help = [
do_delete.aliases = ['rm'];
// TODO: When have 'tritonsnapshot' completion, then use this:
// do_get.completionArgtypes = ['tritoninstance', 'tritonsnapshot'];
do_delete.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_delete;

View File

@ -77,4 +77,8 @@ do_get.help = [
'{{options}}'
].join('\n');
// TODO: When have 'tritonsnapshot' completion, then use this:
// do_get.completionArgtypes = ['tritoninstance', 'tritonsnapshot', 'none'];
do_get.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_get;

View File

@ -93,6 +93,8 @@ do_list.help = [
'{{options}}'
].join('\n');
do_list.completionArgtypes = ['tritoninstance', 'none'];
do_list.aliases = ['ls'];
module.exports = do_list;

View File

@ -86,4 +86,8 @@ do_ssh.help = (
do_ssh.interspersedOptions = false;
// Use 'file' to fallback to the default bash completion... even though 'file'
// isn't quite right.
do_ssh.completionArgtypes = ['tritoninstance', 'file'];
module.exports = do_ssh;

View File

@ -109,4 +109,6 @@ do_delete.help = [
do_delete.aliases = ['rm'];
do_delete.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_delete;

View File

@ -65,4 +65,7 @@ do_get.help = [
/* END JSSTYLED */
].join('\n');
// TODO: When have 'tritoninstancetag' completion, add that in.
do_get.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_get;

View File

@ -66,4 +66,6 @@ do_list.help = [
do_list.aliases = ['ls'];
do_list.completionArgtypes = ['tritoninstance', 'none'];
module.exports = do_list;

View File

@ -129,4 +129,6 @@ do_replace_all.help = [
/* END JSSTYLED */
].join('\n');
do_replace_all.completionArgtypes = ['tritoninstance', 'file'];
module.exports = do_replace_all;

View File

@ -130,4 +130,6 @@ do_set.help = [
/* END JSSTYLED */
].join('\n');
do_set.completionArgtypes = ['tritoninstance', 'file'];
module.exports = do_set;

View File

@ -137,4 +137,6 @@ do_wait.options = [
}
];
do_wait.completionArgtypes = ['tritoninstance'];
module.exports = do_wait;

View File

@ -66,6 +66,8 @@ function gen_do_ACTION(opts) {
}
];
do_ACTION.completionArgtypes = ['tritoninstance'];
if (action === 'start') {
do_ACTION.options.push({
names: ['snapshot'],

View File

@ -116,4 +116,6 @@ do_delete.help = [
do_delete.aliases = ['rm'];
do_delete.completionArgtypes = ['tritonkey'];
module.exports = do_delete;

View File

@ -77,4 +77,6 @@ do_get.help = [
'Where "KEY" is an SSH key "name" or "fingerprint".'
].join('\n');
do_get.completionArgtypes = ['tritonkey', 'none'];
module.exports = do_get;

View File

@ -60,4 +60,6 @@ do_get.help = (
+ '{{options}}'
);
do_get.completionArgtypes = ['tritonnetwork', 'none'];
module.exports = do_get;

View File

@ -66,4 +66,6 @@ do_get.help = (
/* END JSSTYLED */
);
do_get.completionArgtypes = ['tritonpackage', 'none'];
module.exports = do_get;

View File

@ -129,6 +129,7 @@ do_delete.help = [
'{{options}}'
].join('\n');
do_delete.completionArgtypes = ['tritonprofile', 'none'];
do_delete.aliases = ['rm'];

View File

@ -163,5 +163,6 @@ do_edit.help = [
'{{options}}'
].join('\n');
do_edit.completionArgtypes = ['tritonprofile', 'none'];
module.exports = do_edit;

View File

@ -10,6 +10,8 @@
* `triton reboot ...` bwcompat shortcut for `triton instance reboot ...`.
*/
var targ = require('./do_instance/do_reboot');
function do_reboot(subcmd, opts, args, callback) {
this.handlerFromSubcmd('instance').dispatch({
subcmd: 'reboot',
@ -19,6 +21,7 @@ function do_reboot(subcmd, opts, args, callback) {
}
do_reboot.help = 'A shortcut for "triton instance reboot".';
do_reboot.options = require('./do_instance/do_reboot').options;
do_reboot.options = targ.options;
do_reboot.completionArgtypes = targ.completionArgtypes;
module.exports = do_reboot;

View File

@ -10,6 +10,8 @@
* `triton ssh ...` bwcompat shortcut for `triton instance ssh ...`.
*/
var targ = require('./do_instance/do_ssh');
function do_ssh(subcmd, opts, args, callback) {
this.handlerFromSubcmd('instance').dispatch({
subcmd: 'ssh',
@ -19,6 +21,7 @@ function do_ssh(subcmd, opts, args, callback) {
}
do_ssh.help = 'A shortcut for "triton instance ssh".';
do_ssh.options = require('./do_instance/do_ssh').options;
do_ssh.options = targ.options;
do_ssh.completionArgtypes = targ.completionArgtypes;
module.exports = do_ssh;

View File

@ -10,6 +10,8 @@
* `triton start ...` bwcompat shortcut for `triton instance start ...`.
*/
var targ = require('./do_instance/do_start');
function do_start(subcmd, opts, args, callback) {
this.handlerFromSubcmd('instance').dispatch({
subcmd: 'start',
@ -19,6 +21,7 @@ function do_start(subcmd, opts, args, callback) {
}
do_start.help = 'A shortcut for "triton instance start".';
do_start.options = require('./do_instance/do_start').options;
do_start.options = targ.options;
do_start.completionArgtypes = targ.completionArgtypes;
module.exports = do_start;

View File

@ -10,6 +10,8 @@
* `triton stop ...` bwcompat shortcut for `triton instance stop ...`.
*/
var targ = require('./do_instance/do_stop');
function do_stop(subcmd, opts, args, callback) {
this.handlerFromSubcmd('instance').dispatch({
subcmd: 'stop',
@ -19,6 +21,7 @@ function do_stop(subcmd, opts, args, callback) {
}
do_stop.help = 'A shortcut for "triton instance stop".';
do_stop.options = require('./do_instance/do_stop').options;
do_stop.options = targ.options;
do_stop.completionArgtypes = targ.completionArgtypes;
module.exports = do_stop;

View File

@ -1271,7 +1271,7 @@ function waitForInstanceTagChanges(opts, cb) {
if (elapsedTime > timeout) {
cb(new errors.TimeoutError(format('timeout waiting for '
+ 'tag changes on instance %s (elapsed %ds)',
opts.id, Math.round(elapsedTime * 1000))));
opts.id, Math.round(elapsedTime / 1000))));
} else {
setTimeout(poll, POLL_INTERVAL);
}

View File

@ -1,7 +1,7 @@
{
"name": "triton",
"description": "Joyent Triton CLI and client (https://www.joyent.com/triton)",
"version": "4.6.1",
"version": "4.7.0",
"author": "Joyent (joyent.com)",
"dependencies": {
"assert-plus": "0.2.0",