From aeebcf19f018bdcaf63ba51ab607766406ac0f96 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Mon, 28 Sep 2015 12:20:21 -0700 Subject: [PATCH] 'triton profile -a' from stdin, JSON file or interactively --- TODO.txt | 2 + lib/common.js | 100 +++++++++++++++++++++- lib/config.js | 71 +++++++++------- lib/do_profile.js | 207 ++++++++++++++++++++++++++++++++++++++++++++- lib/do_profiles.js | 32 +------ package.json | 5 +- 6 files changed, 350 insertions(+), 67 deletions(-) diff --git a/TODO.txt b/TODO.txt index a7b1894..35acc86 100644 --- a/TODO.txt +++ b/TODO.txt @@ -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 diff --git a/lib/common.js b/lib/common.js index c66099b..827bd72 100644 --- a/lib/common.js +++ b/lib/common.js @@ -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([, []]) + * 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 , 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: diff --git a/lib/config.js b/lib/config.js index 87fd443..f241fdb 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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, diff --git a/lib/do_profile.js b/lib/do_profile.js index 495bf62..789f6df 100644 --- a/lib/do_profile.js +++ b/lib/do_profile.js @@ -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'); diff --git a/lib/do_profiles.js b/lib/do_profiles.js index e3595b9..ccdf875 100644 --- a/lib/do_profiles.js +++ b/lib/do_profiles.js @@ -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 = [ diff --git a/package.json b/package.json index ffc8fd9..1d52d20 100644 --- a/package.json +++ b/package.json @@ -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"