f1e90cdab8
Fixes #43
612 lines
18 KiB
JavaScript
612 lines
18 KiB
JavaScript
/*
|
|
* Copyright (c) 2015 Joyent Inc.
|
|
*
|
|
* `triton profile ...`
|
|
*/
|
|
|
|
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');
|
|
var errors = require('./errors');
|
|
var mod_config = require('./config');
|
|
|
|
|
|
|
|
function _showProfile(opts, cb) {
|
|
assert.object(opts.cli, 'opts.cli');
|
|
assert.string(opts.name, 'opts.name');
|
|
assert.func(cb, 'cb');
|
|
var cli = opts.cli;
|
|
|
|
try {
|
|
var profile = mod_config.loadProfile({
|
|
configDir: cli.configDir,
|
|
name: opts.name
|
|
});
|
|
} catch (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
if (profile.name === cli.tritonapi.profile.name) {
|
|
cli._applyProfileOverrides(profile);
|
|
profile.curr = true;
|
|
} else {
|
|
profile.curr = false;
|
|
}
|
|
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(profile));
|
|
} else {
|
|
Object.keys(profile).sort().forEach(function (key) {
|
|
var val = profile[key];
|
|
console.log('%s: %s', key, val);
|
|
});
|
|
}
|
|
}
|
|
|
|
function _currentProfile(opts, cb) {
|
|
assert.object(opts.cli, 'opts.cli');
|
|
assert.string(opts.name, 'opts.name');
|
|
assert.func(cb, 'cb');
|
|
var cli = opts.cli;
|
|
|
|
try {
|
|
var profile = mod_config.loadProfile({
|
|
configDir: cli.configDir,
|
|
name: opts.name
|
|
});
|
|
} catch (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
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('Set "%s" as current profile', profile.name);
|
|
cb();
|
|
});
|
|
}
|
|
|
|
|
|
function _yamlishFromProfile(profile) {
|
|
assert.object(profile, 'profile');
|
|
|
|
var keys = [];
|
|
var skipKeys = ['curr', 'name'];
|
|
Object.keys(profile).forEach(function (key) {
|
|
if (skipKeys.indexOf(key) === -1) {
|
|
keys.push(key);
|
|
}
|
|
});
|
|
keys = keys.sort();
|
|
|
|
var lines = [];
|
|
keys.forEach(function (key) {
|
|
lines.push(format('%s: %s', key, profile[key]));
|
|
});
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function _profileFromYamlish(yamlish) {
|
|
assert.string(yamlish, 'yamlish');
|
|
|
|
var profile = {};
|
|
var bools = ['insecure'];
|
|
var lines = yamlish.split(/\n/g);
|
|
lines.forEach(function (line) {
|
|
var commentIdx = line.indexOf('#');
|
|
if (commentIdx !== -1) {
|
|
line = line.slice(0, commentIdx);
|
|
}
|
|
line = line.trim();
|
|
if (!line) {
|
|
return;
|
|
}
|
|
var parts = strsplit(line, ':', 2);
|
|
var key = parts[0].trim();
|
|
var value = parts[1].trim();
|
|
if (bools.indexOf(key) !== -1) {
|
|
value = common.boolFromString(value);
|
|
}
|
|
profile[key] = value;
|
|
});
|
|
|
|
return profile;
|
|
}
|
|
|
|
|
|
function _editProfile(opts, cb) {
|
|
assert.object(opts.cli, 'opts.cli');
|
|
assert.string(opts.name, 'opts.name');
|
|
assert.func(cb, 'cb');
|
|
var cli = opts.cli;
|
|
|
|
if (opts.name === 'env') {
|
|
return cb(new errors.UsageError('cannot edit "env" profile'));
|
|
}
|
|
|
|
try {
|
|
var profile = mod_config.loadProfile({
|
|
configDir: cli.configDir,
|
|
name: opts.name
|
|
});
|
|
} catch (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
var filename = format('profile-%s.txt', profile.name);
|
|
var origText = _yamlishFromProfile(profile);
|
|
|
|
function editAttempt(text) {
|
|
common.editInEditor({
|
|
text: text,
|
|
filename: filename
|
|
}, function (err, afterText, changed) {
|
|
if (err) {
|
|
return cb(new errors.TritonError(err));
|
|
} else if (!changed) {
|
|
console.log('No change to profile');
|
|
return cb();
|
|
}
|
|
|
|
try {
|
|
var editedProfile = _profileFromYamlish(afterText);
|
|
editedProfile.name = profile.name;
|
|
|
|
if (_yamlishFromProfile(editedProfile) === origText) {
|
|
// This YAMLish is the closest to a canonical form we have.
|
|
console.log('No change to profile');
|
|
return cb();
|
|
}
|
|
|
|
mod_config.saveProfileSync({
|
|
configDir: cli.configDir,
|
|
profile: editedProfile
|
|
});
|
|
} catch (textErr) {
|
|
console.error('Error with your changes: %s', textErr);
|
|
common.promptEnter(
|
|
'Press <Enter> to re-edit, Ctrl+C to abort.',
|
|
function (aborted) {
|
|
if (aborted) {
|
|
console.log('\nAborting. ' +
|
|
'No change made to profile');
|
|
cb();
|
|
} else {
|
|
editAttempt(afterText);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
cb();
|
|
});
|
|
}
|
|
|
|
editAttempt(origText);
|
|
}
|
|
|
|
|
|
function _deleteProfile(opts, cb) {
|
|
assert.object(opts.cli, 'opts.cli');
|
|
assert.string(opts.name, 'opts.name');
|
|
assert.bool(opts.force, 'opts.force');
|
|
assert.func(cb, 'cb');
|
|
var cli = opts.cli;
|
|
|
|
if (opts.name === 'env') {
|
|
return cb(new errors.UsageError('cannot delete "env" profile'));
|
|
}
|
|
|
|
try {
|
|
var profile = mod_config.loadProfile({
|
|
configDir: cli.configDir,
|
|
name: opts.name
|
|
});
|
|
} catch (err) {
|
|
if (opts.force) {
|
|
cb();
|
|
} else {
|
|
cb(err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (profile.name === cli.tritonapi.profile.name && !opts.force) {
|
|
return cb(new errors.TritonError(
|
|
'cannot delete the current profile (use --force to override)'));
|
|
}
|
|
|
|
vasync.pipeline({funcs: [
|
|
function confirm(_, next) {
|
|
if (opts.force) {
|
|
return next();
|
|
}
|
|
common.promptYesNo({
|
|
msg: 'Delete profile "' + opts.name + '"? [y/n] '
|
|
}, function (answer) {
|
|
if (answer !== 'y') {
|
|
console.error('Aborting');
|
|
next(true); // early abort signal
|
|
} else {
|
|
next();
|
|
}
|
|
});
|
|
},
|
|
|
|
function handleConfigVar(_, next) {
|
|
if (profile.name === cli.tritonapi.profile.name) {
|
|
_currentProfile({name: 'env', cli: cli}, next);
|
|
} else {
|
|
next();
|
|
}
|
|
},
|
|
|
|
function deleteIt(_, next) {
|
|
try {
|
|
mod_config.deleteProfile({
|
|
configDir: cli.configDir,
|
|
name: opts.name
|
|
});
|
|
} catch (delErr) {
|
|
return next(delErr);
|
|
}
|
|
console.log('Deleted profile "%s"', opts.name);
|
|
next();
|
|
}
|
|
]}, function (err) {
|
|
if (err === true) {
|
|
err = null;
|
|
}
|
|
cb(err);
|
|
});
|
|
}
|
|
|
|
|
|
function _addProfile(opts, cb) {
|
|
assert.object(opts.cli, 'opts.cli');
|
|
assert.optionalString(opts.file, 'opts.file');
|
|
assert.func(cb, 'cb');
|
|
var cli = opts.cli;
|
|
var log = cli.log;
|
|
|
|
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) {
|
|
log.trace({stdin: stdin}, 'invalid profile JSON on stdin');
|
|
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(format(
|
|
'"%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);
|
|
}
|
|
|
|
|
|
|
|
function do_profile(subcmd, opts, args, cb) {
|
|
if (opts.help) {
|
|
this.do_help('help', {}, [subcmd], cb);
|
|
return;
|
|
}
|
|
|
|
// Which action?
|
|
var actions = [];
|
|
if (opts.add) { actions.push('add'); }
|
|
if (opts.current) { actions.push('current'); }
|
|
if (opts.edit) { actions.push('edit'); }
|
|
if (opts['delete']) { actions.push('delete'); }
|
|
var action;
|
|
if (actions.length === 0) {
|
|
action = 'show';
|
|
} else if (actions.length > 1) {
|
|
return cb(new errors.UsageError(
|
|
'only one action option may be used at once'));
|
|
} else {
|
|
action = actions[0];
|
|
}
|
|
|
|
// Arg count validation.
|
|
if (args.length > 1) {
|
|
return cb(new errors.UsageError('too many arguments'));
|
|
} else if (args.length === 0 &&
|
|
['current', 'delete'].indexOf(action) !== -1)
|
|
{
|
|
return cb(new errors.UsageError('NAME argument is required'));
|
|
}
|
|
|
|
switch (action) {
|
|
case 'show':
|
|
_showProfile({
|
|
cli: this,
|
|
name: args[0] || this.tritonapi.profile.name,
|
|
json: opts.json
|
|
}, cb);
|
|
break;
|
|
case 'current':
|
|
_currentProfile({cli: this, name: args[0]}, cb);
|
|
break;
|
|
case 'edit':
|
|
_editProfile({
|
|
cli: this,
|
|
name: args[0] || this.tritonapi.profile.name
|
|
}, cb);
|
|
break;
|
|
case 'delete':
|
|
_deleteProfile({
|
|
cli: this,
|
|
name: args[0],
|
|
force: Boolean(opts.force)
|
|
}, cb);
|
|
break;
|
|
case 'add':
|
|
_addProfile({cli: this, file: args[0]}, cb);
|
|
break;
|
|
default:
|
|
return cb(new errors.InternalError('unknown action: ' + action));
|
|
}
|
|
}
|
|
|
|
do_profile.options = [
|
|
{
|
|
names: ['help', 'h'],
|
|
type: 'bool',
|
|
help: 'Show this help.'
|
|
},
|
|
{
|
|
names: ['json', 'j'],
|
|
type: 'bool',
|
|
help: 'JSON output when showing a profile.'
|
|
},
|
|
{
|
|
names: ['force', 'f'],
|
|
type: 'bool',
|
|
help: 'Force deletion.'
|
|
},
|
|
{
|
|
group: 'Action Options'
|
|
},
|
|
{
|
|
names: ['current', 'c'],
|
|
type: 'bool',
|
|
help: 'Switch to the named profile.'
|
|
},
|
|
{
|
|
names: ['edit', 'e'],
|
|
type: 'bool',
|
|
help: 'Edit the named profile in your $EDITOR.'
|
|
},
|
|
{
|
|
names: ['add', 'a'],
|
|
type: 'bool',
|
|
help: 'Add a new profile.'
|
|
},
|
|
{
|
|
names: ['delete', 'd'],
|
|
type: 'bool',
|
|
help: 'Delete the named profile.'
|
|
}
|
|
];
|
|
|
|
do_profile.help = [
|
|
'Show, add, edit and delete `triton` CLI profiles.',
|
|
'',
|
|
'A profile is a configured Triton CloudAPI endpoint. I.e. the',
|
|
'url, account, key, etc. information required to call a CloudAPI.',
|
|
'You can then switch between profiles with `triton -p PROFILE`',
|
|
'or the TRITON_PROFILE environment variable.',
|
|
'',
|
|
'Usage:',
|
|
' {{name}} profile [NAME] # show NAME or current profile',
|
|
' {{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. 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');
|
|
|
|
|
|
module.exports = do_profile;
|