From 5ee94b04af11b5032b09e8d8948eb977c21d9576 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Thu, 14 Jan 2016 22:56:38 -0800 Subject: [PATCH] joyent/node-triton#75 'triton account update ...' --- CHANGES.md | 4 +- lib/cloudapi2.js | 65 ++++++++- lib/common.js | 85 ++++++++++- lib/do_account/do_update.js | 172 +++++++++++++++++++++++ lib/do_account/index.js | 4 +- package.json | 2 +- test/integration/cli-account.test.js | 47 ++++++- test/integration/cli-subcommands.test.js | 1 + 8 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 lib/do_account/do_update.js diff --git a/CHANGES.md b/CHANGES.md index 0963eda..49cbd90 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,8 @@ # node-triton changelog -## 4.1.1 (not yet released) +## 4.2.0 (not yet released) -(nothing yet) +- #75 `triton account update ...` ## 4.1.0 diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index fa94215..ed1c921 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -273,6 +273,7 @@ CloudApi.prototype._passThrough = function _passThrough(endpoint, opts, cb) { * at SecurePair.emit (events.js:92:17) * * TODO: could generalize this into a wrapErr method. + * TODO: this should be on _request, no? So that PUT, POST, etc. get it. */ if (err && err.message === 'DEPTH_ZERO_SELF_SIGNED_CERT' && self.client.rejectUnauthorized) @@ -352,6 +353,60 @@ CloudApi.prototype.getAccount = function getAccount(opts, cb) { }; +// -> +CloudApi.prototype.UPDATE_ACCOUNT_FIELDS = { + email: 'string', + companyName: 'string', + firstName: 'string', + lastName: 'string', + address: 'string', + postalCode: 'string', + city: 'string', + state: 'string', + country: 'string', + phone: 'string', + triton_cns_enabled: 'boolean' +}; + +/** + * Update account fields. + * + * + * @param opts {Object} A key for each account field to update. + * @param cb {Function} `function (err, updatedAccount, res)` + */ +CloudApi.prototype.updateAccount = function updateAccount(opts, cb) { + assert.object(opts, 'opts'); + assert.func(cb, 'cb'); + + var self = this; + var update = {}; + var unexpectedFields = []; + Object.keys(opts).forEach(function (field) { + var type = self.UPDATE_ACCOUNT_FIELDS[field]; + if (type) { + assert[type === 'boolean' ? 'bool' : type](opts[field], + 'opts.'+field); + update[field] = opts[field]; + } else { + unexpectedFields.push(field); + } + }); + if (unexpectedFields.length > 0) { + throw new Error(format('unknown field(s) for UpdateAccount: %s', + unexpectedFields.sort().join(', '))); + } + + this._request({ + method: 'POST', + path: format('/%s', this.account), + data: update + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + + /** * List account's SSH keys. * @@ -1400,6 +1455,10 @@ CloudApi.prototype.setRoleTags = function setRoleTags(opts, cb) { // --- Exports -module.exports.createClient = function (options) { - return new CloudApi(options); -}; +module.exports = { + createClient: function createClient(options) { + return new CloudApi(options); + }, + + CloudApi: CloudApi +}; \ No newline at end of file diff --git a/lib/common.js b/lib/common.js index ce8caa5..5969c8f 100644 --- a/lib/common.js +++ b/lib/common.js @@ -15,6 +15,7 @@ var fs = require('fs'); var os = require('os'); var path = require('path'); var read = require('read'); +var strsplit = require('strsplit'); var tty = require('tty'); var util = require('util'), format = util.format; @@ -873,6 +874,87 @@ function tildeSync(s) { } +/** + * Transform an array of 'key=value' CLI arguments to an object. + * + * - The use of '.' in the key allows sub-object assignment (only one level + * deep). This can be disabled with `opts.disableDotted`. + * - An attempt will be made the `JSON.parse` a given value, such that + * booleans, numbers, objects, arrays can be specified; at the expense + * of not being able to specify, e.g., a literal 'true' string. + * If `opts.typeHintFromKey` states that a key is a string, this JSON.parse + * is NOT done. + * - An empty 'value' is transformed to `null`. Note that 'null' also + * JSON.parse's as `null`. + * + * Example: + * > objFromKeyValueArgs(['nm=foo', 'tag.blah=true', 'empty=', 'nada=null']); + * { nm: 'foo', + * tag: { blah: true }, + * empty: null, + * nada: null } + * + * @param args {Array} Array of string args to process. + * @param opts {Object} Optional. + * - @param disableDotted {Boolean} Optional. Set to true to disable + * dotted keys. + * - @param typeHintFromKey {Object} Optional. Type hints for input keys. + * E.g. if parsing 'foo=false' and `typeHintFromKey={foo: 'string'}`, + * then we do NOT parse it to a boolean `false`. + */ +function objFromKeyValueArgs(args, opts) +{ + assert.arrayOfString(args, 'args'); + assert.optionalObject(opts, 'opts'); + opts = opts || {}; + assert.optionalBool(opts.disableDotted, 'opts.disableDotted'); + assert.optionalObject(opts.typeHintFromKey, opts.typeHintFromKey); + var typeHintFromKey = opts.typeHintFromKey || {}; + + var obj = {}; + args.forEach(function (arg) { + var kv = strsplit(arg, '=', 2); + if (kv.length < 2) { + throw new TypeError(format('invalid key=value argument: "%s"')); + } + + var k = kv[0]; + var t = typeHintFromKey[k]; + + var v = kv[1]; + if (t === 'string') { + // Leave `v` a string. + /* jsl:pass */ + } else if (v === '') { + v = null; + } else { + try { + v = JSON.parse(v); + } catch (e) { + /* pass */ + } + } + + if (opts.disableDotted) { + obj[k] = v; + } else { + var dotted = strsplit(k, '.', 2); + if (dotted.length > 1) { + if (!obj[dotted[0]]) { + obj[dotted[0]] = {}; + } + obj[dotted[0]][dotted[1]] = v; + } else { + obj[k] = v; + } + } + }); + + return obj; +} + + + //---- exports module.exports = { @@ -903,6 +985,7 @@ module.exports = { generatePassword: generatePassword, execPlus: execPlus, deepEqual: deepEqual, - tildeSync: tildeSync + tildeSync: tildeSync, + objFromKeyValueArgs: objFromKeyValueArgs }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_account/do_update.js b/lib/do_account/do_update.js new file mode 100644 index 0000000..c10f18c --- /dev/null +++ b/lib/do_account/do_update.js @@ -0,0 +1,172 @@ +/* + * 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 2016 Joyent, Inc. + * + * `triton account update ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var fs = require('fs'); +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); +var UPDATE_ACCOUNT_FIELDS + = require('../cloudapi2').CloudApi.prototype.UPDATE_ACCOUNT_FIELDS; + + +function do_update(subcmd, opts, args, callback) { + if (opts.help) { + this.do_help('help', {}, [subcmd], callback); + return; + } + + var log = this.log; + var tritonapi = this.top.tritonapi; + + vasync.pipeline({arg: {}, funcs: [ + function gatherDataArgs(ctx, next) { + if (opts.file) { + next(); + return; + } + try { + ctx.data = common.objFromKeyValueArgs(args, { + disableDotted: true, + typeHintFromKey: UPDATE_ACCOUNT_FIELDS + }); + } catch (err) { + next(err); + return; + } + next(); + }, + + function gatherDataFile(ctx, next) { + if (!opts.file || opts.file === '-') { + next(); + return; + } + var input = fs.readFileSync(opts.file, 'utf8'); + try { + ctx.data = JSON.parse(input); + } catch (err) { + next(new errors.TritonError(format( + 'invalid JSON for account update in "%s": %s', + opts.file, err))); + return; + } + next(); + }, + + function gatherDataStdin(ctx, next) { + if (opts.file !== '-') { + next(); + return; + } + var stdin = ''; + process.stdin.resume(); + process.stdin.on('data', function (chunk) { + stdin += chunk; + }); + process.stdin.on('end', function () { + try { + ctx.data = JSON.parse(stdin); + } catch (err) { + log.trace({stdin: stdin}, + 'invalid account update JSON on stdin'); + next(new errors.TritonError(format( + 'invalid JSON for account update on stdin: %s', err))); + return; + } + next(); + }); + }, + + function validateIt(ctx, next) { + var keys = Object.keys(ctx.data); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var value = ctx.data[key]; + var type = UPDATE_ACCOUNT_FIELDS[key]; + if (!type) { + next(new errors.UsageError(format('unknown or ' + + 'unupdateable field: %s (updateable fields are: %s)', + key, + Object.keys(UPDATE_ACCOUNT_FIELDS).sort().join(', ')))); + return; + } + + if (typeof (value) !== type) { + next(new errors.UsageError(format('field "%s" must be ' + + 'of type "%s", but got a value of type "%s"', key, + type, typeof (value)))); + return; + } + } + next(); + }, + + function updateAway(ctx, next) { + var keys = Object.keys(ctx.data); + if (keys.length === 0) { + console.log('No fields given for account update'); + next(); + return; + } + + tritonapi.cloudapi.updateAccount(ctx.data, function (err) { + if (err) { + next(err); + return; + } + console.log('Updated account "%s" (fields: %s)', + tritonapi.profile.account, keys.join(', ')); + next(); + }); + } + ]}, callback); +} + +do_update.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['file', 'f'], + type: 'string', + helpArg: 'FILE', + help: 'A file holding a JSON file of updates, or "-" to read ' + + 'JSON from stdin.' + } +]; +do_update.help = [ + /* BEGIN JSSTYLED */ + 'Update account information', + '', + 'Usage:', + ' {{name}} update [FIELD=VALUE ...]', + ' {{name}} update -f JSON-FILE', + '', + '{{options}}', + + 'Updateable fields:', + ' ' + Object.keys(UPDATE_ACCOUNT_FIELDS).sort().map(function (field) { + return field + ' (' + UPDATE_ACCOUNT_FIELDS[field] + ')'; + }).join('\n '), + + '', + 'Note that because of cross-data center replication of account information, ', + 'an update might not be immediately reflected in a get.' + /* END JSSTYLED */ +].join('\n'); + +module.exports = do_update; diff --git a/lib/do_account/index.js b/lib/do_account/index.js index e70a53f..a79f1c9 100644 --- a/lib/do_account/index.js +++ b/lib/do_account/index.js @@ -31,7 +31,8 @@ function AccountCLI(top) { }, helpSubcmds: [ 'help', - 'get' + 'get', + 'update' ] }); } @@ -43,6 +44,7 @@ AccountCLI.prototype.init = function init(opts, args, cb) { }; AccountCLI.prototype.do_get = require('./do_get'); +AccountCLI.prototype.do_update = require('./do_update'); module.exports = AccountCLI; diff --git a/package.json b/package.json index cb8c853..6390ac3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "triton", "description": "Joyent Triton CLI and client (https://www.joyent.com/triton)", - "version": "4.1.1", + "version": "4.2.0", "author": "Joyent (joyent.com)", "dependencies": { "assert-plus": "0.2.0", diff --git a/test/integration/cli-account.test.js b/test/integration/cli-account.test.js index 888bdb4..bcb251b 100644 --- a/test/integration/cli-account.test.js +++ b/test/integration/cli-account.test.js @@ -19,6 +19,9 @@ var test = require('tape'); // --- Globals +var writeTestOpts = { + skip: !h.CONFIG.allowWriteActions +}; // --- Tests @@ -53,13 +56,55 @@ test('triton account', function (tt) { }); }); + var account; tt.test(' triton account get -j', function (t) { h.triton('account get -j', function (err, stdout, stderr) { if (h.ifErr(t, err)) return t.end(); - var account = JSON.parse(stdout); + account = JSON.parse(stdout); t.equal(account.login, h.CONFIG.profile.account, 'account.login'); t.end(); }); }); + + tt.test(' triton account update foo=bar', writeTestOpts, function (t) { + h.triton('account update foo=bar', function (err, stdout, stderr) { + t.ok(err); + t.end(); + }); + }); + + tt.test(' triton account update companyName=foo', writeTestOpts, + function (t) { + h.triton('account update companyName=foo', function (err, _o, _e) { + if (h.ifErr(t, err)) + return t.end(); + + /* + * Limitation: because x-dc replication, the update might not be + * reflected in a get right away. + * TODO: poll 'account get' until a timeout, or implement that + * with 'triton account update -w' and use that. + */ + //h.triton('account get -j', function (err2, stdout, stderr) { + // if (h.ifErr(t, err2)) + // return t.end(); + // var updatedAccount = JSON.parse(stdout); + // t.equal(updatedAccount.companyName, 'foo', + // '.companyName'); + // t.end(); + //}); + t.end(); + }); + }); + + tt.test(' triton account update companyName=', writeTestOpts, + function (t) { + h.triton('account update companyName=' + account.companyName || '', + function (err, _o, _e) { + if (h.ifErr(t, err)) + return t.end(); + t.end(); + }); + }); }); diff --git a/test/integration/cli-subcommands.test.js b/test/integration/cli-subcommands.test.js index 26ac03e..189b485 100644 --- a/test/integration/cli-subcommands.test.js +++ b/test/integration/cli-subcommands.test.js @@ -30,6 +30,7 @@ var subs = [ ['profile delete', 'profile rm'], ['account'], ['account get'], + ['account update'], ['services'], ['datacenters'], ['instance', 'inst'],