diff --git a/CHANGES.md b/CHANGES.md index 05076ea..52b4644 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,12 @@ Known issues: ## not yet released +## 5.2.0 + +- [joyent/mode-triton#173] Add support for listing and getting triton nfs + volumes. +- [joyent/mode-triton#174] Add support for creating triton nfs volumes. +- [joyent/mode-triton#175] Add support for deleting triton nfs volumes. - [joyent/node-triton#183] `triton profile create` will no longer use ANSI codes for styling if stdout isn't a TTY. diff --git a/lib/cli.js b/lib/cli.js index a8f3cc5..08c0454 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -205,11 +205,12 @@ function CLI() { 'reboot', 'ssh', 'ip', - { group: 'Images, Packages, Networks, Firewall Rules' }, + { group: 'Images, Packages, Networks, Firewall Rules, Volumes' }, 'image', 'package', 'network', 'fwrule', + 'volume', { group: 'Other Commands' }, 'info', 'account', @@ -695,6 +696,9 @@ CLI.prototype.do_cloudapi = require('./do_cloudapi'); CLI.prototype.do_badger = require('./do_badger'); CLI.prototype.do_rbac = require('./do_rbac'); +// Volumes +CLI.prototype.do_volumes = require('./do_volumes'); +CLI.prototype.do_volume = require('./do_volume'); //---- mainline diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index f872baa..b546b25 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -2275,7 +2275,118 @@ CloudApi.prototype.setRoleTags = function setRoleTags(opts, cb) { }); }; +/** + * Get a volume by id. + * + * @param {Object} opts + * - id {UUID} Required. The volume id. + * @param {Function} cb of the form `function (err, volume, res)` + */ +CloudApi.prototype.getVolume = function getVolume(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + var endpoint = format('/%s/volumes/%s', this.account, opts.id); + this._request(endpoint, function (err, req, res, body) { + cb(err, body, res); + }); +}; + +/** + * List the account's volumes. + * + * @param {Object} options + * @param {Function} callback - called like `function (err, volumes)` + */ +CloudApi.prototype.listVolumes = function listVolumes(options, cb) { + var endpoint = format('/%s/volumes', this.account); + this._passThrough(endpoint, options, cb); +}; + +/** + * Create a volume for the account. + * + * @param {Object} options + * - name {String} Optional: the name of the volume to be created + * - size {String} Optional: a string representing the size of the volume + * to be created + * - networks {Array} Optional: an array that contains all the networks + * that should be reachable from the newly created volume + * @param {Function} callback - called like `function (err, volume, res)` + */ +CloudApi.prototype.createVolume = function createVolume(options, callback) { + assert.object(options, 'options'); + assert.optionalString(options.name, 'options.name'); + assert.optionalString(options.size, 'options.size'); + assert.optionalArrayOfUuid(options.networks, 'options.networks'); + assert.func(callback, 'callback'); + + this._request({ + method: 'POST', + path: format('/%s/volumes', this.account), + data: options + }, function (err, req, res, body) { + callback(err, body, res); + }); +}; + +/** + * Delete an account's volume. + * + * @param {String} volumeUuid + * @param {Function} callback - called like `function (err, volume, res)` + */ +CloudApi.prototype.deleteVolume = function deleteVolume(volumeUuid, callback) { + assert.uuid(volumeUuid, 'volumeUuid'); + assert.func(callback, 'callback'); + + this._request({ + method: 'DELETE', + path: format('/%s/volumes/%s', this.account, volumeUuid) + }, function (err, req, res, body) { + callback(err, body, res); + }); +}; + + +/** + * Wait for a volume to go one of a set of specfic states. + * + * @param {Object} options + * - {String} id - machine UUID + * - {Array of String} states - desired state + * - {Number} interval (optional) - time in ms to poll + * @param {Function} callback - called when state is reached or on error + */ +CloudApi.prototype.waitForVolumeStates = +function waitForVolumeStates(opts, callback) { + var self = this; + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.arrayOfString(opts.states, 'opts.states'); + assert.optionalNumber(opts.interval, 'opts.interval'); + assert.func(callback, 'callback'); + var interval = (opts.interval === undefined ? 1000 : opts.interval); + assert.ok(interval > 0, 'interval must be a positive number'); + + poll(); + + function poll() { + self.getVolume({ + id: opts.id + }, function (err, volume, res) { + if (err) { + callback(err, null, res); + return; + } + if (opts.states.indexOf(volume.state) !== -1) { + callback(null, volume, res); + return; + } + setTimeout(poll, interval); + }); + } +}; // --- Exports diff --git a/lib/common.js b/lib/common.js index fe54f0a..56bd521 100644 --- a/lib/common.js +++ b/lib/common.js @@ -156,6 +156,49 @@ function kvToObj(kvs, valid) { return o; } +/** + * given an array of key=value pairs, break them into a JSON predicate + * + * @param {Array} kvs - an array of key=value pairs + * @param {Array} valid (optional) - an array to validate pairs + * @param {String} compositionType - the way each key/value pair will be + * combined to form a JSON predicate. Valid values are 'or' and 'and' + * + */ +function kvToJSONPredicate(kvs, valid, compositionType) { + if (typeof ('compositionType') === 'undefined') { + compositionType = valid; + valid = undefined; + } + + assert.arrayOfString(kvs, 'kvs'); + assert.optionalArrayOfString(valid, 'valid'); + assert.string(compositionType, 'string'); + assert.ok(compositionType === 'or' || compositionType === 'and', + 'compositionType'); + + var predicate = {}; + predicate[compositionType] = []; + + for (var i = 0; i < kvs.length; i++) { + var kv = kvs[i]; + var idx = kv.indexOf('='); + if (idx === -1) + throw new errors.UsageError(format( + 'invalid filter: "%s" (must be of the form "field=value")', + kv)); + var k = kv.slice(0, idx); + var v = kv.slice(idx + 1); + if (valid && valid.indexOf(k) === -1) + throw new errors.UsageError(format( + 'invalid filter name: "%s" (must be one of "%s")', + k, valid.join('", "'))); + predicate[compositionType].push({eq: [k, v]}); + } + + return predicate; +} + /** * return how long ago something happened * @@ -1115,6 +1158,7 @@ module.exports = { execPlus: execPlus, deepEqual: deepEqual, tildeSync: tildeSync, - objFromKeyValueArgs: objFromKeyValueArgs + objFromKeyValueArgs: objFromKeyValueArgs, + kvToJSONPredicate: kvToJSONPredicate }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_volume/do_create.js b/lib/do_volume/do_create.js new file mode 100644 index 0000000..8f38175 --- /dev/null +++ b/lib/do_volume/do_create.js @@ -0,0 +1,179 @@ +/* + * 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 2017 Joyent, Inc. + * + * `triton volume create ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var vasync = require('vasync'); + +var common = require('../common'); +var distractions = require('../distractions'); +var errors = require('../errors'); + + +function do_create(subcmd, opts, args, callback) { + var self = this; + + if (opts.help) { + this.do_help('help', {}, [subcmd], callback); + return; + } + + var context = {}; + + vasync.pipeline({funcs: [ + function setup(ctx, next) { + common.cliSetupTritonApi({ + cli: self.top + }, function onSetup(setupErr) { + next(setupErr); + }); + }, + function getNetworks(ctx, next) { + if (!opts.network) { + return next(); + } + + ctx.networks = []; + + vasync.forEachParallel({ + inputs: opts.network, + func: function getNetwork(networkName, nextNet) { + self.top.tritonapi.getNetwork(networkName, + function onGetNetwork(getNetErr, net) { + if (net) { + ctx.networks.push(net); + } + + nextNet(getNetErr); + }); + } + }, next); + }, + function createVolume(ctx, next) { + var createVolumeParams = { + type: 'tritonnfs', + name: opts.name, + networks: ctx.networks, + size: opts.size + }; + + if (opts.type) { + createVolumeParams.type = opts.type; + } + + self.top.tritonapi.cloudapi.createVolume(createVolumeParams, + function onRes(volCreateErr, volume) { + ctx.volume = volume; + next(volCreateErr); + }); + }, + function maybeWait(ctx, next) { + if (!opts.wait) { + next(); + return; + } + + var distraction = distractions.createDistraction(opts.wait.length); + + self.top.tritonapi.cloudapi.waitForVolumeStates({ + id: ctx.volume.id, + states: ['ready', 'failed'] + }, function onWaitDone(waitErr, volume) { + distraction.destroy(); + ctx.volume = volume; + next(waitErr); + }); + }, + function outputRes(ctx, next) { + assert.object(ctx.volume, 'ctx.volume'); + + if (opts.json) { + console.log(JSON.stringify(ctx.volume)); + } else { + console.log(JSON.stringify(ctx.volume, null, 4)); + } + } + ], arg: context}, callback); +} + +do_create.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + group: 'Create options' + }, + { + names: ['name', 'n'], + helpArg: 'NAME', + type: 'string', + help: 'Volume name. If not given, one will be generated server-side.' + }, + { + names: ['type', 't'], + helpArg: 'TYPE', + type: 'string', + help: 'Volume type. Default is "tritonnfs".' + }, + { + names: ['size', 'S'], + type: 'string', + helpArg: 'SIZE', + help: '', + completionType: 'volumesize' + }, + { + names: ['network', 'N'], + type: 'arrayOfCommaSepString', + helpArg: 'NETWORK', + help: 'One or more comma-separated networks (ID, name or short id). ' + + 'This option can be used multiple times.', + completionType: 'tritonnetwork' + }, + { + group: 'Other options' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + }, + { + names: ['wait', 'w'], + type: 'arrayOfBool', + help: 'Wait for the creation to complete. Use multiple times for a ' + + 'spinner.' + } +]; + +do_create.synopses = ['{{name}} {{cmd}} [OPTIONS] VOLUME']; + +do_create.help = [ + /* BEGIN JSSTYLED */ + 'Create a volume.', + '', + '{{usage}}', + '', + '{{options}}', + '', + 'Where VOLUME is a package id (full UUID), exact name, or short id.', + '', + 'Note: Currently this dumps prettified JSON by default. That might change', + 'in the future. Use "-j" to explicitly get JSON output.' + /* END JSSTYLED */ +].join('\n'); + +do_create.completionArgtypes = ['tritonvolume', 'none']; + +module.exports = do_create; diff --git a/lib/do_volume/do_delete.js b/lib/do_volume/do_delete.js new file mode 100644 index 0000000..0316639 --- /dev/null +++ b/lib/do_volume/do_delete.js @@ -0,0 +1,152 @@ +/* + * 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 2017 Joyent, Inc. + * + * `triton volume del ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var vasync = require('vasync'); + +var common = require('../common'); +var distractions = require('../distractions'); +var errors = require('../errors'); + +function perror(err) { + console.error('error: %s', err.message); +} +function deleteVolume(volumeName, options, callback) { + assert.string(volumeName, 'volumeName'); + assert.object(options, 'options'); + assert.object(options.tritonapi, 'options.tritonapi'); + assert.func(callback, 'callback'); + + var tritonapi = options.tritonapi; + + vasync.pipeline({funcs: [ + function getVolume(ctx, next) { + tritonapi.getVolume(volumeName, + function onGetVolume(getVolErr, volume) { + if (!getVolErr) { + ctx.volume = volume; + } + + next(getVolErr); + }); + }, + function doDeleteVolume(ctx, next) { + assert.object(ctx.volume, 'ctx.volume'); + + tritonapi.cloudapi.deleteVolume(ctx.volume.id, + next); + }, + function waitForVolumeStates(ctx, next) { + assert.object(ctx.volume, 'ctx.volume'); + + var distraction; + var volumeId = ctx.volume.id; + + if (!options.wait) { + next(); + return; + } + + distraction = distractions.createDistraction(options.wait.length); + + tritonapi.cloudapi.waitForVolumeStates({ + id: volumeId, + states: ['deleted', 'failed'] + }, function onWaitDone(waitErr, volume) { + distraction.destroy(); + next(waitErr); + }); + } + ], arg: {}}, callback); +} + +function do_delete(subcmd, opts, args, callback) { + var self = this; + + if (opts.help) { + self.do_help('help', {}, [subcmd], callback); + return; + } else if (args.length < 1) { + callback(new errors.UsageError('missing VOLUME arg(s)')); + return; + } + + var context = { + volumeIds: args + }; + + vasync.pipeline({funcs: [ + function setup(ctx, next) { + common.cliSetupTritonApi({ + cli: self.top + }, function onSetup(setupErr) { + next(setupErr); + }); + }, + function deleteVolumes(ctx, next) { + vasync.forEachParallel({ + func: function doDeleteVolume(volumeId, done) { + deleteVolume(volumeId, { + wait: opts.wait, + tritonapi: self.top.tritonapi + }, done); + }, + inputs: ctx.volumeIds + }, next); + } + ], arg: context}, function onDone(err) { + if (err) { + perror(err); + callback(err); + return; + } + + console.log('%s volume %s', common.capitalize('delete'), args); + + callback(); + }); +} + +do_delete.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + group: 'Other options' + }, + { + names: ['wait', 'w'], + type: 'arrayOfBool', + help: 'Wait for the deletion to complete. Use multiple times for a ' + + 'spinner.' + } +]; + +do_delete.synopses = ['{{name}} {{cmd}} [OPTIONS] VOLUME [VOLUME ...]']; + +do_delete.help = [ + 'Deletes a volume.', + '', + '{{usage}}', + '', + '{{options}}', + '', + 'Where VOLUME is a volume id (full UUID), exact name, or short id.' +].join('\n'); + +do_delete.completionArgtypes = ['tritonvolume', 'none']; +do_delete.aliases = ['rm']; + +module.exports = do_delete; diff --git a/lib/do_volume/do_get.js b/lib/do_volume/do_get.js new file mode 100644 index 0000000..c492e61 --- /dev/null +++ b/lib/do_volume/do_get.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 2017 Joyent, Inc. + * + * `triton volume get ...` + */ + +var format = require('util').format; + +var common = require('../common'); +var errors = require('../errors'); + +function do_get(subcmd, opts, args, callback) { + if (opts.help) { + this.do_help('help', {}, [subcmd], callback); + return; + } else if (args.length !== 1) { + return callback(new errors.UsageError(format( + 'incorrect number of args (%d)', args.length))); + } + + var tritonapi = this.top.tritonapi; + common.cliSetupTritonApi({cli: this.top}, function onSetup(setupErr) { + if (setupErr) { + callback(setupErr); + } + tritonapi.getVolume(args[0], function onRes(err, volume) { + if (err) { + return callback(err); + } + + if (opts.json) { + console.log(JSON.stringify(volume)); + } else { + console.log(JSON.stringify(volume, null, 4)); + } + callback(); + }); + }); +} + +do_get.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; + +do_get.synopses = ['{{name}} {{cmd}} [OPTIONS] VOLUME']; + +do_get.help = [ + /* BEGIN JSSTYLED */ + 'Get a volume.', + '', + '{{usage}}', + '', + '{{options}}', + '', + 'Where VOLUME is a volume id (full UUID), exact name, or short id.', + '', + 'Note: Currently this dumps prettified JSON by default. That might change', + 'in the future. Use "-j" to explicitly get JSON output.' + /* END JSSTYLED */ +].join('\n'); + +do_get.completionArgtypes = ['tritonvolume', 'none']; + +module.exports = do_get; diff --git a/lib/do_volume/do_list.js b/lib/do_volume/do_list.js new file mode 100644 index 0000000..cde1e09 --- /dev/null +++ b/lib/do_volume/do_list.js @@ -0,0 +1,136 @@ +/* + * 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 image list ...` + */ + +var format = require('util').format; +var tabula = require('tabula'); + +var common = require('../common'); +var errors = require('../errors'); + +var validFilters = [ + 'name', + 'size', + 'state', + 'owner', + 'type' +]; + +// columns default without -o +var columnsDefault = 'shortid,name,size,type,state'; + +// columns default with -l +var columnsDefaultLong = 'id,name,size,type,state'; + +// sort default with -s +var sortDefault = 'create_timestamp'; + +function do_list(subcmd, opts, args, callback) { + var self = this; + + if (opts.help) { + this.do_help('help', {}, [subcmd], callback); + return; + } + + var columns = columnsDefault; + if (opts.o) { + columns = opts.o; + } else if (opts.long) { + columns = columnsDefaultLong; + } + columns = columns.split(','); + + var sort = opts.s.split(','); + + var filterPredicate; + var listOpts; + + if (args) { + try { + filterPredicate = common.kvToJSONPredicate(args, validFilters, + 'and'); + } catch (e) { + callback(e); + return; + } + } + + if (opts.all === undefined) { + filterPredicate = { + and: [ + { ne: ['state', 'deleted']}, + { ne: ['state', 'failed']} + ] + }; + } + + if (filterPredicate) { + listOpts = { + predicate: JSON.stringify(filterPredicate) + }; + } + + common.cliSetupTritonApi({cli: this.top}, function onSetup(setupErr) { + if (setupErr) { + callback(setupErr); + } + self.top.tritonapi.cloudapi.listVolumes(listOpts, + function onRes(listVolsErr, volumes, res) { + if (listVolsErr) { + return callback(listVolsErr); + } + + if (opts.json) { + common.jsonStream(volumes); + } else { + for (var i = 0; i < volumes.length; i++) { + var volume = volumes[i]; + volume.shortid = volume.id.split('-', 1)[0]; + } + + tabula(volumes, { + skipHeader: opts.H, + columns: columns, + sort: sort + }); + } + callback(); + }); + }); +} + +do_list.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +].concat(common.getCliTableOptions({ + includeLong: true, + sortDefault: sortDefault +})); + +do_list.synopses = ['{{name}} {{cmd}} [OPTIONS]']; + +do_list.help = [ + /* BEGIN JSSTYLED */ + 'List volumes.', + , + '{{usage}}', + '', + '{{options}}' + /* END JSSTYLED */ +].join('\n'); + +do_list.aliases = ['ls']; + +module.exports = do_list; diff --git a/lib/do_volume/index.js b/lib/do_volume/index.js new file mode 100644 index 0000000..c83c7bd --- /dev/null +++ b/lib/do_volume/index.js @@ -0,0 +1,51 @@ +/* + * 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 2017 Joyent, Inc. + * + * `triton volume ...` + */ + +var Cmdln = require('cmdln').Cmdln; +var util = require('util'); + +function VolumeCLI(top) { + this.top = top; + Cmdln.call(this, { + name: top.name + ' volume', + /* BEGIN JSSTYLED */ + desc: [ + 'List and manage Triton volumes.' + ].join('\n'), + /* END JSSTYLED */ + helpOpts: { + minHelpCol: 24 /* line up with option help */ + }, + helpSubcmds: [ + 'help', + 'list', + 'get', + 'create', + 'delete' + ] + }); +} +util.inherits(VolumeCLI, Cmdln); + +VolumeCLI.prototype.init = function init(opts, args, cb) { + this.log = this.top.log; + Cmdln.prototype.init.apply(this, arguments); +}; + +VolumeCLI.prototype.do_list = require('./do_list'); +VolumeCLI.prototype.do_get = require('./do_get'); +VolumeCLI.prototype.do_create = require('./do_create'); +VolumeCLI.prototype.do_delete = require('./do_delete'); + +VolumeCLI.aliases = ['vol']; + +module.exports = VolumeCLI; diff --git a/lib/do_volumes.js b/lib/do_volumes.js new file mode 100644 index 0000000..e3a36bc --- /dev/null +++ b/lib/do_volumes.js @@ -0,0 +1,26 @@ +/* + * 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 20167 Joyent, Inc. + * + * `triton volumes ...` bwcompat shortcut for `triton volumes list ...`. + */ + +function do_volumes(subcmd, opts, args, callback) { + this.handlerFromSubcmd('volume').dispatch({ + subcmd: 'list', + opts: opts, + args: args + }, callback); +} + +do_volumes.help = 'A shortcut for "triton volumes list".'; +do_volumes.aliases = ['vols']; +do_volumes.hidden = true; +do_volumes.options = require('./do_volume/do_list').options; + +module.exports = do_volumes; diff --git a/lib/tritonapi.js b/lib/tritonapi.js index c5f159a..7fd327a 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -2602,6 +2602,71 @@ function _waitForInstanceUpdate(opts, cb) { setImmediate(poll); }; +/** + * Get a volume by ID, exact name, or short ID, in that order. + * + * If there is more than one volume with that name, then this errors out. + */ +TritonApi.prototype.getVolume = function getVolume(name, cb) { + assert.string(name, 'name'); + assert.func(cb, 'cb'); + + if (common.isUUID(name)) { + this.cloudapi.getVolume({id: name}, function (err, pkg) { + if (err) { + if (err.restCode === 'ResourceNotFound') { + err = new errors.ResourceNotFoundError(err, + format('volume with id %s was not found', name)); + } + cb(err); + } else { + cb(null, pkg); + } + }); + } else { + this.cloudapi.listVolumes({ + predicate: JSON.stringify({ + and: [ + { ne: ['state', 'deleted']}, + { ne: ['state', 'failed']} + ] + }) + }, function (err, volumes) { + if (err) { + return cb(err); + } + var nameMatches = []; + var shortIdMatches = []; + for (var i = 0; i < volumes.length; i++) { + var volume = volumes[i]; + if (volume.name === name) { + nameMatches.push(volume); + } + if (volume.id.slice(0, 8) === name) { + shortIdMatches.push(volume); + } + } + + if (nameMatches.length === 1) { + cb(null, nameMatches[0]); + } else if (nameMatches.length > 1) { + cb(new errors.TritonError(format( + 'volume name "%s" is ambiguous: matches %d volumes', + name, nameMatches.length))); + } else if (shortIdMatches.length === 1) { + cb(null, shortIdMatches[0]); + } else if (shortIdMatches.length === 0) { + cb(new errors.ResourceNotFoundError(format( + 'no volume with name or short id "%s" was found', name))); + } else { + cb(new errors.ResourceNotFoundError( + format('no volume with name "%s" was found ' + + 'and "%s" is an ambiguous short id', name))); + } + }); + } +}; + //---- exports module.exports = { diff --git a/package.json b/package.json index c395bc7..bff9b76 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": "5.1.0", + "version": "5.2.0", "author": "Joyent (joyent.com)", "homepage": "https://github.com/joyent/node-triton", "dependencies": { diff --git a/test/integration/cli-subcommands.test.js b/test/integration/cli-subcommands.test.js index 3c83c37..0609179 100644 --- a/test/integration/cli-subcommands.test.js +++ b/test/integration/cli-subcommands.test.js @@ -97,7 +97,12 @@ var subs = [ ['rbac image-role-tags'], ['rbac network-role-tags'], ['rbac package-role-tags'], - ['rbac role-tags'] + ['rbac role-tags'], + ['volume', 'vol'], + ['volume list', 'volume ls', 'volumes', 'vols'], + ['volume delete', 'volume rm'], + ['volume create'], + ['volume get'] ]; // --- Tests diff --git a/test/integration/cli-volumes.test.js b/test/integration/cli-volumes.test.js new file mode 100644 index 0000000..97af277 --- /dev/null +++ b/test/integration/cli-volumes.test.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 2017, Joyent, Inc. + */ + +/* + * Test volume create command. + */ + +var format = require('util').format; +var os = require('os'); +var test = require('tape'); +var vasync = require('vasync'); + +var common = require('../../lib/common'); +var h = require('./helpers'); + +var testOpts = { + skip: !h.CONFIG.allowWriteActions +}; + +test('triton volume create ...', testOpts, function (tt) { + var validVolumeName = + h.makeResourceName('node-triton-test-volume-create-default'); + + tt.comment('Test config:'); + Object.keys(h.CONFIG).forEach(function (key) { + var value = h.CONFIG[key]; + tt.comment(format('- %s: %j', key, value)); + }); + + tt.test(' cleanup leftover resources', function (t) { + h.triton(['volume', 'delete', '-w', validVolumeName].join(' '), + function onDelVolume(delVolErr, stdout, stderr) { + t.end(); + }); + }); + + tt.test(' triton volume create with invalid name', function (t) { + var invalidVolumeName = + h.makeResourceName('node-triton-test-volume-create-invalid-name-' + + '!foo!'); + var expectedErrMsg = 'triton volume create: error (InvalidArgument): ' + + 'Error: Invalid volume name: ' + invalidVolumeName; + + h.triton([ + 'volume', + 'create', + '--name', + invalidVolumeName + ].join(' '), function (volCreateErr, stdout, stderr) { + t.equal(stderr.indexOf(expectedErrMsg), 0, + 'stderr should include error message: ' + expectedErrMsg); + t.end(); + }); + }); + + tt.test(' triton volume create with invalid size', function (t) { + var invalidSize = 'foobar'; + var expectedErrMsg = 'triton volume create: error (InvalidArgument): ' + + 'Error: Invalid volume size: ' + invalidSize; + var volumeName = + h.makeResourceName('node-triton-test-volume-create-invalid-size'); + + h.triton([ + 'volume', + 'create', + '--name', + volumeName, + '--size', + invalidSize + ].join(' '), function (volCreateErr, stdout, stderr) { + t.equal(stderr.indexOf(expectedErrMsg), 0, + 'stderr should include error message: ' + expectedErrMsg); + t.end(); + }); + }); + + tt.test(' triton volume create with invalid type', function (t) { + var invalidType = 'foobar'; + var volumeName = + h.makeResourceName('node-triton-test-volume-create-invalid-type'); + var expectedErrMsg = 'triton volume create: error (InvalidArgument): ' + + 'Error: Invalid volume type: ' + invalidType; + + h.triton([ + 'volume', + 'create', + '--name', + volumeName, + '--type', + invalidType + ].join(' '), function (volCreateErr, stdout, stderr) { + t.equal(stderr.indexOf(expectedErrMsg), 0, + 'stderr should include error message: ' + expectedErrMsg); + t.end(); + }); + }); + + tt.test(' triton volume create with invalid network', function (t) { + var volumeName = + h.makeResourceName('node-triton-test-volume-create-invalid-' + + 'network'); + var invalidNetwork = 'foobar'; + var expectedErrMsg = + 'triton volume create: error: first of 1 error: no network with ' + + 'name or short id "' + invalidNetwork + '" was found'; + + h.triton([ + 'volume', + 'create', + '--name', + volumeName, + '--network', + invalidNetwork + ].join(' '), function (volCreateErr, stdout, stderr) { + t.equal(stderr.indexOf(expectedErrMsg), 0, + 'stderr should include error message: ' + expectedErrMsg); + t.end(); + }); + }); + + tt.test(' triton volume create valid volume', function (t) { + h.triton([ + 'volume', + 'create', + '--name', + validVolumeName, + '-w' + ].join(' '), function (volCreateErr, stdout, stderr) { + t.equal(volCreateErr, null, + 'volume creation should not error'); + t.end(); + }); + }); + + tt.test(' check volume was created', function (t) { + h.safeTriton(t, ['volume', 'get', validVolumeName], + function onGetVolume(getVolErr, stdout) { + t.equal(getVolErr, null, + 'Getting volume should not error'); + t.end(); + }); + }); + + tt.test(' delete volume', function (t) { + h.triton(['volume', 'delete', '-w', validVolumeName].join(' '), + function onDelVolume(delVolErr, stdout, stderr) { + t.equal(delVolErr, null, + 'Deleting volume should not error'); + t.end(); + }); + }); + + tt.test(' check volume was deleted', function (t) { + h.triton(['volume', 'get', validVolumeName].join(' '), + function onGetVolume(getVolErr, stdout, stderr) { + t.ok(getVolErr, + 'Getting volume ' + validVolumeName + 'after deleting it ' + + 'should errorr'); + t.notEqual(stderr.indexOf('ResourceNotFound'), -1, + 'Getting volume ' + validVolumeName + 'should not find it'); + t.end(); + }); + }); + +}); diff --git a/test/integration/helpers.js b/test/integration/helpers.js index 8a573a6..7dc6786 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -12,9 +12,10 @@ * Test helpers for the integration tests */ -var error = console.error; var assert = require('assert-plus'); +var error = console.error; var f = require('util').format; +var os = require('os'); var path = require('path'); var tabula = require('tabula'); @@ -359,6 +360,14 @@ function printConfig(t) { }); } +/* + * Returns a string that represents a unique resource name for the host on which + * this function is called. + */ +function makeResourceName(prefix) { + assert.string(prefix, 'prefix'); + return prefix + '-' + os.hostname(); +} // --- exports @@ -373,6 +382,7 @@ module.exports = { getTestPkg: getTestPkg, getResizeTestPkg: getResizeTestPkg, jsonStreamParse: jsonStreamParse, + makeResourceName: makeResourceName, printConfig: printConfig, ifErr: testcommon.ifErr