diff --git a/lib/cli.js b/lib/cli.js index 3fb9160..7061d28 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -212,7 +212,7 @@ function CLI() { { group: 'Other Commands' }, 'info', 'account', - 'keys', + 'key', 'services', 'datacenters' ], @@ -337,6 +337,9 @@ CLI.prototype.do_account = require('./do_account'); CLI.prototype.do_services = require('./do_services'); CLI.prototype.do_datacenters = require('./do_datacenters'); CLI.prototype.do_info = require('./do_info'); + +// Account keys +CLI.prototype.do_key = require('./do_key'); CLI.prototype.do_keys = require('./do_keys'); // Images diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 2b379eb..a6b5bc4 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -351,14 +351,103 @@ CloudApi.prototype.getAccount = function getAccount(opts, cb) { this._passThrough(endpoint, opts, cb); }; + /** * List account's SSH keys. * + * @param {Object} opts (object) * @param {Function} callback of the form `function (err, keys, res)` */ CloudApi.prototype.listKeys = function listKeys(opts, cb) { + assert.object(opts, 'opts'); + assert.func(cb, 'cb'); + var endpoint = format('/%s/keys', this.account); - this._passThrough(endpoint, opts, cb); + this._passThrough(endpoint, {}, cb); +}; + + +/** + * Get an account's SSH key. + * + * @param {Object} opts (object) + * - {String} fingerprint (required*) The SSH key fingerprint. One of + * 'fingerprint' or 'name' is required. + * - {String} name (required*) The SSH key name. One of 'fingerprint' + * or 'name' is required. + * @param {Function} callback of the form `function (err, res)` + */ +CloudApi.prototype.getKey = function getKey(opts, cb) { + assert.object(opts, 'opts'); + assert.optionalString(opts.fingerprint, 'opts.fingerprint'); + assert.optionalString(opts.name, 'opts.name'); + assert.func(cb, 'cb'); + + var identifier = opts.fingerprint || opts.name; + assert.ok(identifier, 'one of "fingerprint" or "name" is require'); + + var endpoint = format('/%s/keys/%s', this.account, + encodeURIComponent(identifier)); + this._passThrough(endpoint, {}, cb); +}; + + +/** + * Create/upload a new account SSH public key. + * + * @param {Object} opts (object) + * - {String} key (required) The SSH public key content. + * - {String} name (optional) A name for the key. If not given, the + * key fingerprint will be used. + * @param {Function} callback of the form `function (err, res)` + */ +CloudApi.prototype.createKey = function createKey(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.key, 'opts.key'); + assert.optionalString(opts.name, 'opts.name'); + assert.func(cb, 'cb'); + + var data = { + name: opts.name, + key: opts.key + }; + + this._request({ + method: 'POST', + path: format('/%s/keys', this.account), + data: data + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + + +/** + * Delete an account's SSH key. + * + * @param {Object} opts (object) + * - {String} fingerprint (required*) The SSH key fingerprint. One of + * 'fingerprint' or 'name' is required. + * - {String} name (required*) The SSH key name. One of 'fingerprint' + * or 'name' is required. + * @param {Function} callback of the form `function (err, res)` + */ +CloudApi.prototype.deleteKey = function deleteKey(opts, cb) { + assert.object(opts, 'opts'); + assert.optionalString(opts.fingerprint, 'opts.fingerprint'); + assert.optionalString(opts.name, 'opts.name'); + assert.func(cb, 'cb'); + + var identifier = opts.fingerprint || opts.name; + assert.ok(identifier, 'one of "fingerprint" or "name" is require'); + + this._request({ + method: 'DELETE', + path: format('/%s/keys/%s', this.account, + encodeURIComponent(identifier)) + }, function (err, req, res) { + cb(err, res); + }); }; diff --git a/lib/do_key/do_add.js b/lib/do_key/do_add.js new file mode 100644 index 0000000..3bbc2cf --- /dev/null +++ b/lib/do_key/do_add.js @@ -0,0 +1,140 @@ +/* + * 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 2015 Joyent, Inc. + * + * `triton key add ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var fs = require('fs'); +var sshpk = require('sshpk'); +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + + +function do_add(subcmd, opts, args, cb) { + assert.optionalString(opts.name, 'opts.name'); + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length === 0) { + cb(new errors.UsageError('missing FILE argument')); + return; + } else if (args.length > 1) { + cb(new errors.UsageError('incorrect number of arguments')); + return; + } + + var filePath = args[0]; + var cli = this.top; + + vasync.pipeline({arg: {}, funcs: [ + function gatherDataStdin(ctx, next) { + if (filePath !== '-') { + return next(); + } + + var stdin = ''; + process.stdin.resume(); + process.stdin.on('data', function (chunk) { + stdin += chunk; + }); + + process.stdin.on('end', function () { + ctx.data = stdin; + ctx.from = ''; + next(); + }); + }, + function gatherDataFile(ctx, next) { + if (!filePath || filePath === '-') { + return next(); + } + + ctx.data = fs.readFileSync(filePath); + ctx.from = filePath; + next(); + }, + function validateData(ctx, next) { + try { + sshpk.parseKey(ctx.data, 'ssh', ctx.from); + } catch (keyErr) { + next(keyErr); + return; + } + + next(); + }, + function createIt(ctx, next) { + var createOpts = { + userId: opts.userId, + key: ctx.data.toString('utf8') + }; + + if (opts.name) { + createOpts.name = opts.name; + } + + cli.tritonapi.cloudapi.createKey(createOpts, function (err, key) { + if (err) { + next(err); + return; + } + + if (key.name) { + console.log('Added key "%s" (%s)', + key.name, key.fingerprint); + } else { + console.log('Added key %s', key.fingerprint); + } + + next(); + }); + } + ]}, cb); +} + + +do_add.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + }, + { + names: ['name', 'n'], + type: 'string', + helpArg: 'NAME', + help: 'An optional name for an added key.' + } +]; +do_add.help = [ + 'Add an SSH key to an account.', + '', + 'Usage:', + ' {{name}} key add FILE []', + '', + '{{options}}', + '', + 'Where "FILE" must be a file path to an SSH public key, ', + 'or "-" to pass the public key in on stdin.' +].join('\n'); + +module.exports = do_add; diff --git a/lib/do_key/do_delete.js b/lib/do_key/do_delete.js new file mode 100644 index 0000000..1a18b96 --- /dev/null +++ b/lib/do_key/do_delete.js @@ -0,0 +1,120 @@ +/* + * 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 2015 Joyent, Inc. + * + * `triton key delete ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var fs = require('fs'); +var sshpk = require('sshpk'); +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + + +function do_delete(subcmd, opts, args, cb) { + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length === 0) { + cb(new errors.UsageError('missing KEY argument(s)')); + return; + } + + var cli = this.top; + + vasync.pipeline({funcs: [ + function confirm(_, next) { + if (opts.yes) { + return next(); + } + + var msg; + if (args.length === 1) { + msg = 'Delete key "' + args[0] + '"? [y/n] '; + } else { + msg = format('Delete %d keys (%s)? [y/n] ', + args.length, args.join(', ')); + } + + common.promptYesNo({msg: msg}, function (answer) { + if (answer !== 'y') { + console.error('Aborting'); + next(true); // early abort signal + } else { + next(); + } + }); + }, + function deleteThem(_, next) { + vasync.forEachPipeline({ + inputs: args, + func: function deleteOne(id, nextId) { + var delOpts = { + fingerprint: id + }; + + cli.tritonapi.cloudapi.deleteKey(delOpts, function (err) { + if (err) { + nextId(err); + return; + } + + console.log('Deleted key "%s"', id); + nextId(); + }); + } + }, next); + } + ]}, function (err) { + if (err === true) { + err = null; + } + cb(err); + }); +} + + +do_delete.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + }, + { + names: ['yes', 'y'], + type: 'bool', + help: 'Answer yes to confirmation to delete.' + } +]; +do_delete.help = [ + 'Remove an SSH key from an account.', + '', + 'Usage:', + ' {{name}} key delete FILE []', + '', + '{{options}}', + '', + 'Where "KEY" is an SSH key "name" or "fingerprint".' +].join('\n'); + +do_delete.aliases = ['rm']; + +module.exports = do_delete; diff --git a/lib/do_key/do_get.js b/lib/do_key/do_get.js new file mode 100644 index 0000000..87ddd01 --- /dev/null +++ b/lib/do_key/do_get.js @@ -0,0 +1,80 @@ +/* + * 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 2015 Joyent, Inc. + * + * `triton key get ...` + */ + +var assert = require('assert-plus'); + +var common = require('../common'); +var errors = require('../errors'); + + +function do_get(subcmd, opts, args, cb) { + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length === 0) { + cb(new errors.UsageError('missing KEY argument')); + return; + } else if (args.length > 1) { + cb(new errors.UsageError('incorrect number of arguments')); + return; + } + + var id = args[0]; + var cli = this.top; + + cli.tritonapi.cloudapi.getKey({ + // Currently `cloudapi.getUserKey` isn't picky about the `name` being + // passed in as the `opts.fingerprint` arg. + fingerprint: id + }, function onKey(err, key) { + if (err) { + cb(err); + return; + } + + if (opts.json) { + console.log(JSON.stringify(key)); + } else { + console.log(common.chomp(key.key)); + } + cb(); + }); +} + + +do_get.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; +do_get.help = [ + 'Show a specific SSH key in an account.', + '', + 'Usage:', + ' {{name}} key get KEY # show account\'s KEY', + '', + '{{options}}', + 'Where "KEY" is an SSH key "name" or "fingerprint".' +].join('\n'); + +module.exports = do_get; diff --git a/lib/do_key/do_list.js b/lib/do_key/do_list.js new file mode 100644 index 0000000..e22ac57 --- /dev/null +++ b/lib/do_key/do_list.js @@ -0,0 +1,79 @@ +/* + * 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 2015 Joyent, Inc. + * + * `triton key list ...` + */ + +var assert = require('assert-plus'); + +var common = require('../common'); +var errors = require('../errors'); + + +function do_list(subcmd, opts, args, cb) { + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length > 0) { + cb(new errors.UsageError('incorrect number of arguments')); + return; + } + + var cli = this.top; + + cli.tritonapi.cloudapi.listKeys({}, function onKeys(err, keys) { + if (err) { + cb(err); + return; + } + + if (opts.json) { + console.log(JSON.stringify(keys)); + } else { + keys.forEach(function (key) { + console.log(common.chomp(key.key)); + }); + } + cb(); + }); +} + + +do_list.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; +do_list.help = [ + 'Show all of an account\'s SSH keys.', + '', + 'Usage:', + ' {{name}} key list []', + '', + '{{options}}', + '', + 'By default this lists just the key content for each key -- in other', + 'words, content appropriate for a "~/.ssh/authorized_keys" file.', + 'Use `triton keys -j` to see all fields.' +].join('\n'); + +do_list.aliases = ['ls']; + +module.exports = do_list; diff --git a/lib/do_key/index.js b/lib/do_key/index.js new file mode 100644 index 0000000..7c72ed3 --- /dev/null +++ b/lib/do_key/index.js @@ -0,0 +1,48 @@ +/* + * 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 2015 Joyent, Inc. + * + * `triton key ...` + */ + +var Cmdln = require('cmdln').Cmdln; +var util = require('util'); + + + +// ---- CLI class + +function KeyCLI(top) { + this.top = top; + + Cmdln.call(this, { + name: top.name + ' key', + desc: 'Account SSH key commands.', + helpSubcmds: [ + 'help', + { group: 'Key Resources' }, + 'add', + 'list', + 'get', + 'delete' + ] + }); +} +util.inherits(KeyCLI, Cmdln); + +KeyCLI.prototype.init = function init(opts, args, cb) { + this.log = this.top.log; + Cmdln.prototype.init.apply(this, arguments); +}; + +KeyCLI.prototype.do_add = require('./do_add'); +KeyCLI.prototype.do_get = require('./do_get'); +KeyCLI.prototype.do_list = require('./do_list'); +KeyCLI.prototype.do_delete = require('./do_delete'); + +module.exports = KeyCLI; diff --git a/lib/do_keys.js b/lib/do_keys.js index 877e682..5dffa69 100644 --- a/lib/do_keys.js +++ b/lib/do_keys.js @@ -7,62 +7,21 @@ /* * Copyright 2015 Joyent, Inc. * - * `triton keys ...` + * `triton keys ...` bwcompat shortcut for `triton keys list ...`. */ -var common = require('./common'); -var errors = require('./errors'); - - -function do_keys(subcmd, opts, args, cb) { - if (opts.help) { - this.do_help('help', {}, [subcmd], cb); - return; - } else if (args.length !== 0) { - cb(new errors.UsageError('invalid args: ' + args)); - return; - } - - this.tritonapi.cloudapi.listKeys(function (err, keys) { - if (err) { - cb(err); - return; - } - - if (opts.json) { - common.jsonStream(keys); - } else { - keys.forEach(function (key) { - console.log(common.chomp(key.key)); - }); - } - cb(); - }); +function do_keys(subcmd, opts, args, callback) { + var subcmdArgv = ['node', 'triton', 'key', 'list'].concat(args); + this.dispatch('key', subcmdArgv, callback); } -do_keys.options = [ - { - names: ['help', 'h'], - type: 'bool', - help: 'Show this help.' - }, - { - names: ['json', 'j'], - type: 'bool', - help: 'JSON output.' - } -]; -do_keys.help = ( - 'Show account SSH keys.\n' - + '\n' - + 'Usage:\n' - + ' {{name}} keys []\n' - + '\n' - + '{{options}}' - + '\n' - + 'By default this lists just the key content for each key -- in other\n' - + 'words, content appropriate for a "~/.ssh/authorized_keys" file.\n' - + 'Use `triton keys -j` to see all fields.\n' -); +do_keys.help = [ + 'A shortcut for "triton key list".', + '', + 'Usage:', + ' {{name}} key ...' +].join('\n'); + +do_keys.hidden = true; module.exports = do_keys; diff --git a/lib/do_package/do_list.js b/lib/do_package/do_list.js index bd0fe70..c810a38 100644 --- a/lib/do_package/do_list.js +++ b/lib/do_package/do_list.js @@ -156,7 +156,7 @@ do_list.options = [ do_list.help = [ /* BEGIN JSSTYLED */ - 'List packgaes.', + 'List packages.', '', 'Usage:', ' {{name}} package list []', diff --git a/lib/do_profiles.js b/lib/do_profiles.js index 4ca8775..76331d0 100644 --- a/lib/do_profiles.js +++ b/lib/do_profiles.js @@ -22,8 +22,6 @@ do_profiles.help = [ ' {{name}} profiles ...' ].join('\n'); -do_profiles.aliases = ['imgs']; - do_profiles.hidden = true; module.exports = do_profiles; diff --git a/test/integration/cli-keys.test.js b/test/integration/cli-keys.test.js new file mode 100644 index 0000000..cc5fd61 --- /dev/null +++ b/test/integration/cli-keys.test.js @@ -0,0 +1,95 @@ +/* + * 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 (c) 2015, Joyent, Inc. + */ + +/* + * Integration tests for `triton key ...` + */ + +var h = require('./helpers'); +var test = require('tape'); +var backoff = require('backoff'); + + + +// --- Globals + +var KEY_PATH = 'data/id_rsa.pub'; +var KEY_SIG = '66:ca:1c:09:75:99:35:69:be:91:08:25:03:c0:17:c0'; +var KEY_EMAIL = 'test@localhost.local'; +var MAX_CHECK_KEY_TRIES = 10; + +// --- Tests + +test('triton key', function (tt) { + tt.test(' triton key add', function (t) { + h.triton('key add ' + KEY_PATH, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton key add')) + return t.end(); + + t.ok(stdout.match('Added key "' + KEY_SIG + '"')); + t.end(); + }); + }); + + tt.test(' triton key get', function (t) { + h.triton('key get ' + KEY_SIG, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton key get')) + return t.end(); + + t.ok(stdout.match(KEY_EMAIL)); + t.end(); + }); + }); + + tt.test(' triton key list', function (t) { + h.triton('key list', function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton key list')) + return t.end(); + + // there should always be at least two keys -- the original + // account's key, and the test key these tests added + var keys = stdout.split('\n'); + t.ok(keys.length > 2, 'triton key list expected key num'); + + var testKeys = keys.filter(function (key) { + return key.match(KEY_EMAIL); + }); + + // this test is a tad dodgy, since it's plausible that there might + // be other test keys with different signatures lying around + t.equal(testKeys.length, 1, 'triton key list test key found'); + + t.end(); + }); + }); + + tt.test(' triton key delete', function (t) { + var cmd = 'key delete ' + KEY_SIG + ' --yes'; + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton key delete')) + return t.end(); + + t.ok(stdout.match('Deleted key "' + KEY_SIG + '"')); + + // verify key is gone, which sometimes takes a while + var call = backoff.call(function checkKey(next) { + h.triton('key get ' + KEY_SIG, function (err2) { + next(!err2); + }); + }, function (err3) { + h.ifErr(t, err3, 'triton key delete did not remove key'); + t.end(); + }); + + call.failAfter(MAX_CHECK_KEY_TRIES); + call.start(); + }); + }); +}); diff --git a/test/integration/cli-subcommands.test.js b/test/integration/cli-subcommands.test.js index ca202ba..18b27b4 100644 --- a/test/integration/cli-subcommands.test.js +++ b/test/integration/cli-subcommands.test.js @@ -22,9 +22,13 @@ var common = require('../../lib/common'); var subs = [ ['info'], ['profile'], - ['profiles'], + ['profile list', 'profile ls', 'profiles'], + ['profile get'], + ['profile set-current'], + ['profile create'], + ['profile edit'], + ['profile delete', 'profile rm'], ['account', 'whoami'], - ['keys'], ['services'], ['datacenters'], ['create-instance', 'create'], @@ -37,12 +41,35 @@ var subs = [ ['delete-instance', 'delete'], ['wait-instance', 'wait'], ['ssh'], - ['images', 'imgs'], - ['image', 'img'], - ['packages', 'pkgs'], - ['package', 'pkg'], ['networks'], - ['network'] + ['network'], + ['key'], + ['key add'], + ['key list', 'key ls', 'keys'], + ['key get'], + ['key delete', 'key rm'], + ['image', 'img'], + ['image get'], + ['image list', 'images', 'imgs'], + ['package', 'pkg'], + ['package get'], + ['package list', 'packages', 'pkgs'], + ['rbac'], + ['rbac info'], + ['rbac apply'], + ['rbac users'], + ['rbac user'], + ['rbac keys'], + ['rbac key'], + ['rbac policies'], + ['rbac policy'], + ['rbac roles'], + ['rbac role'], + ['rbac instance-role-tags'], + ['rbac image-role-tags'], + ['rbac network-role-tags'], + ['rbac package-role-tags'], + ['rbac role-tags'] ]; // --- Tests @@ -58,8 +85,11 @@ test('triton subcommands', function (ttt) { // triton help // triton -h subcmds.forEach(function (subcmd) { - tt.test(f(' triton help %s', subcmd), function (t) { - h.triton(['help', subcmd], function (err, stdout, stderr) { + var helpArgs = subcmd.split(' '); + helpArgs.splice(helpArgs.length - 1, 0, 'help'); + + tt.test(f(' triton %s', helpArgs.join(' ')), function (t) { + h.triton(helpArgs, function (err, stdout, stderr) { if (h.ifErr(t, err, 'no error')) return t.end(); t.equal(stderr, '', 'stderr produced'); @@ -69,8 +99,10 @@ test('triton subcommands', function (ttt) { }); }); - tt.test(f(' triton %s -h', subcmd), function (t) { - h.triton([subcmd, '-h'], function (err, stdout, stderr) { + var flagArgs = subcmd.split(' ').concat('-h'); + + tt.test(f(' triton %s', flagArgs.join(' ')), function (t) { + h.triton(flagArgs, function (err, stdout, stderr) { if (h.ifErr(t, err, 'no error')) return t.end(); t.equal(stderr, '', 'stderr produced'); diff --git a/test/integration/data/id_rsa.pub b/test/integration/data/id_rsa.pub new file mode 100644 index 0000000..0f4986a --- /dev/null +++ b/test/integration/data/id_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNr4q9zMKtylAZGr17fjtUfH2gS+6Gx4m3TJ1H5QwC97JCmtTgke/PBRSEacbXjsYlBjJ9DifNpIbrZrP9hOGhknPDyC3EaRUe/TCUCVGRiHFspurxZAiHvfENCQcvDaVcu9/tO3QyGjDSoYSaQ6NNvl8+yPZ6+mGtXeMnXlCWEvhy/fe3yNVp0isvSIinB2paI+pQqmytJ8omCGShdLqq4/Lvw/zbROe6gEb78+mvwqS+fqYDuPjGc5DXZATqM8rjOSKSPllzNILh9MbR3cHDBfhVi77jL9P8FfQv1U1SZBTbZj78xFcBRnGxrxWE0H7FcXldK5vJvafqWgxZZpgb test@localhost.local