'triton profile -a' from stdin, JSON file or interactively

This commit is contained in:
Trent Mick 2015-09-28 12:20:21 -07:00
parent a5ee77a48e
commit aeebcf19f0
6 changed files with 350 additions and 67 deletions

View File

@ -3,6 +3,8 @@ test suite:
- TritonApi testing: test/integration/api-*.test.js
- more test/unit/...
sub-user support (profiles, `triton account`, env, auth)
note in README that full UUIDs is much faster in the API
*type*: cloudapi changes to clarify: LX, docker, smartos, kvm instances

View File

@ -17,11 +17,13 @@ var read = require('read');
var tty = require('tty');
var util = require('util'),
format = util.format;
var wordwrap = require('wordwrap');
var errors = require('./errors'),
InternalError = errors.InternalError;
// ---- support stuff
function objCopy(obj, target) {
@ -541,6 +543,65 @@ function promptEnter(prompt, cb) {
}
/*
* Prompt the user for a value.
*
* @params field {Object}
* - field.desc {String} Optional. A description of the field to print
* before prompting.
* - field.key {String} The field name. Used as the prompt.
* - field.default Optional default value.
* - field.validate {Function} Optional. A validation/manipulation
* function of the form:
* function (value, cb)
* which should callback with
* cb([<error or null>, [<manipulated value>]])
* examples:
* cb(new Error('value is not a number'));
* cb(); // value is fine as is
* cb(null, Math.floor(Number(value))); // manip to a floored int
* @params cb {Function} `function (err, value)`
* If the user aborted, the `err` will be whatever the [read
* package](https://www.npmjs.com/package/read) returns, i.e. a
* string "cancelled".
*/
function promptField(field, cb) {
var wrap = wordwrap(Math.min(process.stdout.columns, 78));
function attempt(next) {
read({
// read/readline prompting messes up width with ANSI codes here.
prompt: field.key + ':',
default: field.default,
edit: true
}, function (err, result, isDefault) {
if (err) {
return cb(err);
}
var value = result.trim();
if (!field.validate) {
return cb(null, value);
}
field.validate(value, function (validationErr, newValue) {
if (validationErr) {
console.log(ansiStylize(
wrap(validationErr.message), 'red'));
attempt();
} else {
if (newValue !== undefined) {
value = newValue;
}
cb(null, value);
}
});
});
}
console.log(ansiStylize(wrap(field.desc), 'bold'));
attempt();
}
/**
* Edit the given text in $EDITOR (defaulting to `vi`) and return the edited
* text.
@ -571,6 +632,42 @@ function editInEditor(opts, cb) {
}
// http://en.wikipedia.org/wiki/ANSI_escape_code#graphics
// Suggested colors (some are unreadable in common cases):
// - Good: cyan, yellow (limited use), bold, green, magenta, red
// - Bad: blue (not visible on cmd.exe), grey (same color as background on
// Solarized Dark theme from <https://github.com/altercation/solarized>, see
// issue #160)
var colors = {
'bold' : [1, 22],
'italic' : [3, 23],
'underline' : [4, 24],
'inverse' : [7, 27],
'white' : [37, 39],
'grey' : [90, 39],
'black' : [30, 39],
'blue' : [34, 39],
'cyan' : [36, 39],
'green' : [32, 39],
'magenta' : [35, 39],
'red' : [31, 39],
'yellow' : [33, 39]
};
function ansiStylize(str, color) {
if (!str)
return '';
var codes = colors[color];
if (codes) {
return '\033[' + codes[0] + 'm' + str +
'\033[' + codes[1] + 'm';
} else {
return str;
}
}
//---- exports
module.exports = {
@ -591,6 +688,7 @@ module.exports = {
getCliTableOptions: getCliTableOptions,
promptYesNo: promptYesNo,
promptEnter: promptEnter,
editInEditor: editInEditor
editInEditor: editInEditor,
ansiStylize: ansiStylize
};
// vim: set softtabstop=4 shiftwidth=4:

View File

@ -64,30 +64,6 @@ var PROFILE_FIELDS = {
// --- internal support stuff
// TODO: improve this validation: use ConfigError's instead of asserts
function _validateProfile(profile, profilePath) {
assert.object(profile, 'profile');
assert.string(profile.name, 'profile.name');
assert.string(profile.url, 'profile.url');
assert.string(profile.account, 'profile.account');
assert.string(profile.keyId, 'profile.keyId');
assert.optionalBool(profile.insecure, 'profile.insecure');
assert.optionalString(profilePath, 'profilePath');
var bogusFields = [];
Object.keys(profile).forEach(function (field) {
if (!PROFILE_FIELDS[field]) {
bogusFields.push(field);
}
});
if (bogusFields.length) {
throw new errors.ConfigError(format(
'extraneous fields in "%s" profile: %s%s', profile.name,
(profilePath ? profilePath + ': ' : ''), bogusFields.join(', ')));
}
}
function configPathFromDir(configDir) {
return path.resolve(configDir, 'config.json');
}
@ -204,6 +180,35 @@ function setConfigVar(opts, cb) {
// --- Profiles
function validateProfile(profile, profilePath) {
assert.object(profile, 'profile');
assert.optionalString(profilePath, 'profilePath');
try {
assert.string(profile.name, 'profile.name');
assert.string(profile.url, 'profile.url');
assert.string(profile.account, 'profile.account');
assert.string(profile.keyId, 'profile.keyId');
assert.optionalBool(profile.insecure, 'profile.insecure');
} catch (err) {
throw new errors.ConfigError(err.message);
}
var bogusFields = [];
Object.keys(profile).forEach(function (field) {
if (!PROFILE_FIELDS[field]) {
bogusFields.push(field);
}
});
if (bogusFields.length) {
throw new errors.ConfigError(format(
'extraneous fields in "%s" profile: %s%s', profile.name,
(profilePath ? profilePath + ': ' : ''), bogusFields.join(', ')));
}
}
/**
* Load the special 'env' profile, which handles details of getting
* values from envvars. Typically we'd piggyback on dashdash's env support
@ -231,7 +236,7 @@ function _loadEnvProfile() {
envProfile.insecure = common.boolFromString(process.env.SDC_TESTING);
}
_validateProfile(envProfile);
validateProfile(envProfile);
return envProfile;
}
@ -255,11 +260,13 @@ function _profileFromPath(profilePath, name) {
}
profile.name = name;
_validateProfile(profile, profilePath);
validateProfile(profile, profilePath);
return profile;
}
function loadProfile(opts) {
assert.string(opts.configDir, 'opts.configDir');
assert.string(opts.name, 'opts.name');
@ -322,22 +329,22 @@ function deleteProfile(opts) {
function saveProfileSync(opts) {
assert.string(opts.configDir, 'opts.configDir');
assert.string(opts.name, 'opts.name');
assert.object(opts.profile, 'opts.profile');
if (opts.name === 'env') {
var name = opts.profile.name;
if (name === 'env') {
throw new Error('cannot save "env" profile');
}
_validateProfile(opts.profile);
validateProfile(opts.profile);
var toSave = common.objCopy(opts.profile);
delete toSave.name;
var profilePath = path.resolve(opts.configDir, 'profiles.d',
opts.name + '.json');
name + '.json');
fs.writeFileSync(profilePath, JSON.stringify(toSave, null, 4), 'utf8');
console.log('Saved profile "%s"', opts.name);
console.log('Saved profile "%s"', name);
}
@ -346,6 +353,8 @@ function saveProfileSync(opts) {
module.exports = {
loadConfig: loadConfig,
setConfigVar: setConfigVar,
validateProfile: validateProfile,
loadProfile: loadProfile,
loadAllProfiles: loadAllProfiles,
deleteProfile: deleteProfile,

View File

@ -6,7 +6,11 @@
var assert = require('assert-plus');
var format = require('util').format;
var fs = require('fs');
var read = require('read');
var strsplit = require('strsplit');
var sshpk = require('sshpk');
var tilde = require('tilde-expansion');
var vasync = require('vasync');
var common = require('./common');
@ -174,7 +178,6 @@ function _editProfile(opts, cb) {
mod_config.saveProfileSync({
configDir: cli.configDir,
name: opts.name,
profile: editedProfile
});
} catch (textErr) {
@ -278,8 +281,199 @@ function _deleteProfile(opts, cb) {
function _addProfile(opts, cb) {
//XXX
cb(new errors.InternalError('_addProfile not yet implemented'));
assert.object(opts.cli, 'opts.cli');
assert.optionalString(opts.file, 'opts.file');
assert.func(cb, 'cb');
var cli = opts.cli;
var context;
var data;
vasync.pipeline({funcs: [
function gatherDataStdin(_, next) {
if (opts.file !== '-') {
return next();
}
var stdin = '';
process.stdin.resume();
process.stdin.on('data', function (chunk) {
stdin += chunk;
});
process.stdin.on('end', function () {
try {
data = JSON.parse(stdin);
} catch (err) {
return next(new errors.TritonError(
format('invalid profile JSON on stdin: %s', err)));
}
next();
});
},
function gatherDataFile(_, next) {
if (!opts.file || opts.file === '-') {
return next();
}
context = opts.file;
var input = fs.readFileSync(opts.file);
try {
data = JSON.parse(input);
} catch (err) {
return next(new errors.TritonError(format(
'invalid profile JSON in "%s": %s', opts.file, err)));
}
next();
},
function gatherDataInteractive(_, next) {
if (opts.file) {
return next();
} else if (!process.stdin.isTTY) {
return next(new errors.UsageError('cannot interactively ' +
'create profile: stdin is not a TTY'));
} else if (!process.stdout.isTTY) {
return next(new errors.UsageError('cannot interactively ' +
'create profile: stdout is not a TTY'));
}
var profiles = mod_config.loadAllProfiles({
configDir: cli.configDir,
log: cli.log
});
var fields = [ {
desc: 'A profile name. A short string to identify a ' +
'CloudAPI endpoint to the `triton` CLI.',
key: 'name',
validate: function validateName(value, valCb) {
var regex = /^[a-z][a-z0-9_.-]*$/;
if (!regex.test(value)) {
return valCb(new Error('Must start with a lowercase ' +
'letter followed by lowercase letters, numbers ' +
'and "_", "." and "-".'));
}
for (var i = 0; i < profiles.length; i++) {
if (profiles[i].name === value) {
return valCb(new Error(format(
'Profile "%s" already exists.', value)));
}
}
valCb();
}
}, {
desc: 'The CloudAPI endpoint URL.',
default: 'https://us-sw-1.api.joyent.com',
key: 'url'
}, {
desc: 'Your account login name.',
key: 'account',
validate: function validateAccount(value, valCb) {
var regex = /^[^\\]{3,}$/;
if (value.length < 3) {
return valCb(new Error(
'Must be at least 3 characters'));
}
if (!regex.test(value)) {
return valCb(new Error('Must not container a "\\"'));
}
valCb();
}
}, {
desc: 'The fingerprint of the SSH key you have registered ' +
'for your account. You may enter a local path to a ' +
'public or private key to have the fingerprint ' +
'calculated for you.',
key: 'keyId',
validate: function validateKeyId(value, valCb) {
// First try as a fingerprint.
try {
sshpk.parseFingerprint(value);
return valCb();
} catch (fpErr) {
}
// Try as a local path.
tilde(value, function (keyPath) {
fs.stat(keyPath, function (statErr, stats) {
if (statErr || !stats.isFile()) {
return valCb(new Error(
'"%s" is neither a valid fingerprint, ' +
'nor an existing file', value));
}
fs.readFile(keyPath, function (readErr, keyData) {
if (readErr) {
return valCb(readErr);
}
var keyType = (keyPath.slice(-4) === '.pub'
? 'ssh' : 'pem');
try {
var key = sshpk.parseKey(keyData, keyType);
} catch (keyErr) {
return valCb(keyErr);
}
var newVal = key.fingerprint('md5').toString();
console.log('Fingerprint: %s', newVal);
valCb(null, newVal);
});
});
});
}
} ];
data = {};
vasync.forEachPipeline({
inputs: fields,
func: function getField(field, nextField) {
if (field.key !== 'name') console.log();
common.promptField(field, function (err, value) {
data[field.key] = value;
nextField(err);
});
}
}, function (err) {
console.log();
next(err);
});
},
function guardAlreadyExists(_, next) {
try {
var profiles = mod_config.loadAllProfiles({
configDir: cli.configDir,
log: cli.log
});
} catch (err) {
return next(err);
}
for (var i = 0; i < profiles.length; i++) {
if (data.name === profiles[i].name) {
return next(new errors.TritonError(format(
'profile "%s" already exists', data.name)));
}
}
next();
},
function validateIt(_, next) {
// We ignore 'curr'. For now at least.
delete data.curr;
try {
mod_config.validateProfile(data, context);
} catch (err) {
return next(err);
}
next();
},
function saveIt(_, next) {
try {
mod_config.saveProfileSync({
configDir: cli.configDir,
profile: data
});
} catch (err) {
return next(err);
}
next();
}
]}, cb);
}
@ -401,7 +595,12 @@ do_profile.help = [
' {{name}} profile -e|--edit [NAME] # edit a profile in $EDITOR',
' {{name}} profile -c|--current NAME # set NAME as current profile',
' {{name}} profile -d|--delete NAME # delete a profile',
' {{name}} profile -a|--add [FILE] # add a new profile',
'',
' {{name}} profile -a|--add [FILE]',
' # Add a new profile. FILE must be a file path (to JSON of',
' # the form from `triton profile -j`, "curr" field is ',
' # ignored) or "-" to pass the profile in on stdin.',
' # Or exclude FILE to interactively add.',
'',
'{{options}}'
].join('\n');

View File

@ -29,7 +29,7 @@ function _listProfiles(cli, opts, args, cb) {
var profiles;
try {
profiles = mod_config.loadAllProfiles({
configDir: cli.tritonapi.config._configDir,
configDir: cli.configDir,
log: cli.log
});
} catch (e) {
@ -69,30 +69,6 @@ function _listProfiles(cli, opts, args, cb) {
cb();
}
function _currentProfile(cli, opts, args, cb) {
var profile = mod_config.loadProfile({
configDir: cli.configDir,
name: opts.current
});
if (cli.tritonapi.profile.name === profile.name) {
console.log('"%s" is already the current profile', profile.name);
return cb();
}
mod_config.setConfigVar({
configDir: cli.configDir,
name: 'profile',
value: profile.name
}, function (err) {
if (err) {
return cb(err);
}
console.log('Switched to "%s" profile', profile.name);
cb();
});
}
function do_profiles(subcmd, opts, args, cb) {
if (opts.help) {
return this.do_help('help', {}, [subcmd], cb);
@ -100,11 +76,7 @@ function do_profiles(subcmd, opts, args, cb) {
return cb(new errors.UsageError('too many args'));
}
if (opts.current) {
_currentProfile(this, opts, args, cb);
} else {
_listProfiles(this, opts, args, cb);
}
_listProfiles(this, opts, args, cb);
}
do_profiles.options = [

View File

@ -19,11 +19,14 @@
"read": "1.0.7",
"restify-clients": "1.1.0",
"restify-errors": "3.0.0",
"sshpk": "1.2.1",
"smartdc-auth": "git+https://github.com/joyent/node-smartdc-auth.git#3be3c1e",
"strsplit": "1.0.0",
"tabula": "1.6.1",
"tilde-expansion": "0.0.0",
"vasync": "1.6.3",
"verror": "1.6.0"
"verror": "1.6.0",
"wordwrap": "1.0.0"
},
"devDependencies": {
"tape": "4.2.0"