diff --git a/CHANGES.md b/CHANGES.md index c972cff..2fba584 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,7 +7,19 @@ Known issues: ## not yet released -(nothing yet) +- [joyent/node-triton#173], [joyent/node-triton#174] and + [joyent/node-triton#175] Add support for creating and managing NFS shared + volumes. New `triton volume` commands are available: + + * `triton volume create` to create NFS shared volumes + * `triton volume list` to list existing volumes + * `triton volume get` to get information about a given volume + * `triton volume delete` to delete one or more volumes + + Use `triton volume --help` to get help on all of these commands. + + Note that these commands are hidden for now. They will be made visible by + default once the server-side support for volumes is shipped in Triton. ## 5.2.1 diff --git a/etc/triton-bash-completion-types.sh b/etc/triton-bash-completion-types.sh index 0167b25..72a7a44 100644 --- a/etc/triton-bash-completion-types.sh +++ b/etc/triton-bash-completion-types.sh @@ -133,6 +133,12 @@ function complete_tritonnetwork { compgen $compgen_opts -W "$candidates" -- "$word" } +function complete_tritonvolume { + local word="$1" + candidates=$(_complete_tritondata volumes) + compgen $compgen_opts -W "$candidates" -- "$word" +} + function complete_tritonfwrule { local word="$1" candidates=$(_complete_tritondata fwrules) diff --git a/lib/cli.js b/lib/cli.js index a8f3cc5..ed9e41e 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -463,6 +463,21 @@ CLI.prototype._emitCompletions = function _emitCompletions(type, cb) { next(); }); break; + case 'volumes': + tritonapi.cloudapi.listVolumes({}, function (err, vols) { + if (err) { + next(err); + return; + } + completions = []; + vols.forEach(function (vol) { + completions.push(vol.name); + completions.push(vol.id); + }); + arg.completions = completions.join('\n') + '\n'; + next(); + }); + break; case 'affinityrules': /* * We exclude ids, in favour of just inst names here. The only @@ -695,6 +710,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 556a074..6a177b8 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -2300,7 +2300,154 @@ 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._passThrough(endpoint, cb); +}; + +/** + * 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 {Number} Optional: a number representing the size of the volume + * to be created in mebibytes. + * - networks {Array} Optional: an array that contains the uuids of all the + * networks that should be reachable from the newly created volume + * - type {String}: the type of the volume. Currently, only "tritonnfs" is + * supported. + * @param {Function} callback - called like `function (err, volume, res)` + */ +CloudApi.prototype.createVolume = function createVolume(options, cb) { + assert.object(options, 'options'); + assert.optionalString(options.name, 'options.name'); + assert.optionalNumber(options.size, 'options.size'); + assert.optionalArrayOfUuid(options.networks, 'options.networks'); + assert.string(options.type, 'options.type'); + assert.func(cb, 'cb'); + + this._request({ + method: 'POST', + path: format('/%s/volumes', this.account), + data: { + name: options.name, + size: options.size, + networks: (options.networks ? options.networks : undefined), + type: options.type + } + }, function (err, req, res, body) { + cb(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, cb) { + assert.uuid(volumeUuid, 'volumeUuid'); + assert.func(cb, 'cb'); + + this._request({ + method: 'DELETE', + path: format('/%s/volumes/%s', this.account, volumeUuid) + }, function (err, req, res, body) { + cb(err, 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 + * - {Number} timeout (optional) - time in ms after which "callback" is + * called with an error object if the volume hasn't yet transitioned to + * one of the states in "opts.states". + * @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.optionalNumber(opts.timeout, 'opts.timeout'); + assert.func(callback, 'callback'); + + var interval = (opts.interval === undefined ? 1000 : opts.interval); + if (opts.timeout !== undefined) { + interval = Math.min(interval, opts.timeout); + } + assert.ok(interval > 0, 'interval must be a positive number'); + + var startTime = process.hrtime(); + var timeout = opts.timeout; + + poll(); + + function poll() { + self.getVolume({ + id: opts.id + }, function (err, vol, res) { + var elapsedTime; + var timedOut = false; + + if (err) { + callback(err, null, res); + return; + } + if (opts.states.indexOf(vol.state) !== -1) { + callback(null, vol, res); + return; + } else { + if (timeout !== undefined) { + elapsedTime = common.monotonicTimeDiffMs(startTime); + if (elapsedTime > timeout) { + timedOut = true; + } + } + + if (timedOut) { + callback(new errors.TimeoutError(format('timeout waiting ' + + 'for state changes on volume %s (elapsed %ds)', + opts.id, Math.round(elapsedTime / 1000)))); + return; + } else { + setTimeout(poll, interval); + return; + } + } + }); + } +}; // --- Exports diff --git a/lib/common.js b/lib/common.js index c48ab1b..572417f 100644 --- a/lib/common.js +++ b/lib/common.js @@ -126,34 +126,125 @@ function jsonStream(arr, stream) { } /** - * given an array of key=value pairs, break them into an object + * Parses the string "kv" of the form 'key=value' and returns an object that + * represents it with the form {'key': value}. If "key"" in the "kv" string is + * not included in the list "validKeys", it throws an error. It also throws an + * error if the string "kv" is malformed. + * + * By default, converts the values as if they were JSON representations of JS + * types, e.g the string 'false' is converted to the boolean primitive "false". + * + * @param {String} kv + * @param {String[]} validKeys: Optional + * @param {Object} options: Optional + * - @param disableTypeConversions {Boolean} Optional. If true, then no + * type conversion of values is performed, and all values are returned as + * strings. + * - @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`. + * - @param failOnEmptyValue {Boolean} Optional - If true, throws an error + * if a given key's value is the empty string. Default is false. + */ +function _parseKeyValue(kv, validKeys, options) { + assert.string(kv, 'kv'); + assert.optionalArrayOfString(validKeys, 'validKeys'); + assert.optionalObject(options, 'options'); + options = options || {}; + assert.optionalBool(options.disableTypeConversions, + 'options.disableTypeConversions'); + assert.optionalObject(options.typeHintFromKey, 'options.typeHintFromKey'); + assert.optionalBool(options.failOnEmptyValue, 'options.failOnEmptyValue'); + + var idx = kv.indexOf('='); + if (idx === -1) { + throw new errors.UsageError(format('invalid key=value: "%s"', kv)); + } + var k = kv.slice(0, idx); + var typeHint; + var v = kv.slice(idx + 1); + + if (validKeys && validKeys.indexOf(k) === -1) { + throw new errors.UsageError(format( + 'invalid key: "%s" (must be one of "%s")', + k, validKeys.join('", "'))); + } + + if (v === '' && options.failOnEmptyValue) { + throw new Error(format('key "%s" must have a value', k)); + } + + if (options.disableTypeConversions !== true) { + if (options.typeHintFromKey !== undefined) { + typeHint = options.typeHintFromKey[k]; + } + + if (typeHint === 'string') { + // Leave `v` a string. + /* jsl:pass */ + } else if (v === '') { + v = null; + } else { + try { + v = JSON.parse(v); + } catch (e) { + /* pass */ + } + } + } + + return { + key: k, + value: v + }; +} + + +/** + * 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 - * - * TODO: merge this with objFromKeyValueArgs ! + * @param {Object[]} validKeys - an array of objects representing valid keys + * that can be used in the first argument "kvs". + * @param {String} compositionType - the way each key/value pair will be + * combined to form a JSON predicate. Valid values are 'or' and 'and'. */ -function kvToObj(kvs, valid) { +function jsonPredFromKv(kvs, validKeys, compositionType) { assert.arrayOfString(kvs, 'kvs'); - assert.optionalArrayOfString(valid, 'valid'); + assert.arrayOfString(validKeys, 'validKeys'); + assert.string(compositionType, 'string'); + assert.ok(compositionType === 'or' || compositionType === 'and', + 'compositionType'); - var o = {}; - 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('", "'))); - o[k] = v; + var keyName; + var predicate = {}; + var parsedKeyValue; + var parsedKeyValues; + var parseOpts = { + disableDotted: true, + validKeys: validKeys, + failOnEmptyValue: true + }; + + if (kvs.length === 0) { + return predicate; } - return o; + + if (kvs.length === 1) { + parsedKeyValue = _parseKeyValue(kvs[0], validKeys, parseOpts); + predicate.eq = [parsedKeyValue.key, parsedKeyValue.value]; + } else { + predicate[compositionType] = []; + parsedKeyValues = objFromKeyValueArgs(kvs, parseOpts); + + for (keyName in parsedKeyValues) { + predicate[compositionType].push({ + eq: [keyName, parsedKeyValues[keyName]] + }); + } + } + + return predicate; } /** @@ -1034,6 +1125,10 @@ function tildeSync(s) { * - @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`. + * - @param validKeys {String[]} Optional. List of valid keys. By default + * all keys are valid. + * - @param failOnEmptyValue {Boolean} Optional. If true, then a key with a + * value that is the empty string throws an error. Default is false. */ function objFromKeyValueArgs(args, opts) { @@ -1041,45 +1136,31 @@ function objFromKeyValueArgs(args, opts) assert.optionalObject(opts, 'opts'); opts = opts || {}; assert.optionalBool(opts.disableDotted, 'opts.disableDotted'); + assert.optionalBool(opts.disableTypeConversions, + 'opts.disableTypeConversions'); assert.optionalObject(opts.typeHintFromKey, opts.typeHintFromKey); - var typeHintFromKey = opts.typeHintFromKey || {}; + assert.optionalArrayOfString(opts.validKeys, 'opts.validKeys'); + assert.optionalBool(opts.failOnEmptyValue, 'opts.failOnEmptyValue'); var obj = {}; args.forEach(function (arg) { - var kv = strsplit(arg, '=', 2); - if (kv.length < 2) { - throw new TypeError(format('invalid key=value argument: "%s"', - arg)); - } - - 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 */ - } - } + var parsedKeyValue = _parseKeyValue(arg, opts.validKeys, { + typeHintFromKey: opts.typeHintFromKey, + disableTypeConversions: opts.disableTypeConversions, + failOnEmptyValue: opts.failOnEmptyValue + }); if (opts.disableDotted) { - obj[k] = v; + obj[parsedKeyValue.key] = parsedKeyValue.value; } else { - var dotted = strsplit(k, '.', 2); + var dotted = strsplit(parsedKeyValue.key, '.', 2); if (dotted.length > 1) { if (!obj[dotted[0]]) { obj[dotted[0]] = {}; } - obj[dotted[0]][dotted[1]] = v; + obj[dotted[0]][dotted[1]] = parsedKeyValue.value; } else { - obj[k] = v; + obj[parsedKeyValue.key] = parsedKeyValue.value; } } }); @@ -1087,6 +1168,25 @@ function objFromKeyValueArgs(args, opts) return obj; } +/** + * Returns the time difference between the current time and the time + * represented by "relativeTo" in milliseconds. It doesn't use the built-in + * `Date` class internally, and instead uses a node facility that uses a + * monotonic clock. Thus, the time difference computed is not subject to time + * drifting due to e.g changes in the wall clock system time. + * + * @param {arrayOfNumber} relativeTo: an array representing the starting time as + * returned by `process.hrtime()` from which to compute the + * time difference. + */ +function monotonicTimeDiffMs(relativeTo) { + assert.arrayOfNumber(relativeTo, 'relativeTo'); + + var diff = process.hrtime(relativeTo); + var ms = (diff[0] * 1e3) + (diff[1] / 1e6); // in milliseconds + return ms; +} + /* * Parse the given line into an argument vector, e.g. for use in sending to @@ -1200,7 +1300,6 @@ module.exports = { zeroPad: zeroPad, boolFromString: boolFromString, jsonStream: jsonStream, - kvToObj: kvToObj, longAgo: longAgo, isUUID: isUUID, humanDurationFromMs: humanDurationFromMs, @@ -1226,6 +1325,8 @@ module.exports = { deepEqual: deepEqual, tildeSync: tildeSync, objFromKeyValueArgs: objFromKeyValueArgs, - argvFromLine: argvFromLine + argvFromLine: argvFromLine, + jsonPredFromKv: jsonPredFromKv, + monotonicTimeDiffMs: monotonicTimeDiffMs }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_image/do_list.js b/lib/do_image/do_list.js index 0f60e3f..5ef8063 100644 --- a/lib/do_image/do_list.js +++ b/lib/do_image/do_list.js @@ -54,7 +54,11 @@ function do_list(subcmd, opts, args, callback) { var listOpts; try { - listOpts = common.kvToObj(args, validFilters); + listOpts = common.objFromKeyValueArgs(args, { + disableDotted: true, + validKeys: validFilters, + disableTypeConversions: true + }); } catch (e) { callback(e); return; diff --git a/lib/do_instance/do_list.js b/lib/do_instance/do_list.js index 224f407..69fcae2 100644 --- a/lib/do_instance/do_list.js +++ b/lib/do_instance/do_list.js @@ -61,7 +61,11 @@ function do_list(subcmd, opts, args, callback) { var listOpts; try { - listOpts = common.kvToObj(args, validFilters); + listOpts = common.objFromKeyValueArgs(args, { + disableDotted: true, + validKeys: validFilters, + disableTypeConversions: true + }); } catch (e) { callback(e); return; diff --git a/lib/do_network/do_list.js b/lib/do_network/do_list.js index 056f3e6..faa4f9e 100644 --- a/lib/do_network/do_list.js +++ b/lib/do_network/do_list.js @@ -48,7 +48,11 @@ function do_list(subcmd, opts, args, callback) { var sort = opts.s.split(','); var filters; try { - filters = common.kvToObj(args, validFilters); + filters = common.objFromKeyValueArgs(args, { + disableDotted: true, + validKeys: validFilters, + disableTypeConversions: true + }); } catch (e) { callback(e); return; diff --git a/lib/do_package/do_list.js b/lib/do_package/do_list.js index d9166ae..9e35101 100644 --- a/lib/do_package/do_list.js +++ b/lib/do_package/do_list.js @@ -63,7 +63,11 @@ function do_list(subcmd, opts, args, callback) { var listOpts; try { - listOpts = common.kvToObj(args, validFilters); + listOpts = common.objFromKeyValueArgs(args, { + disableDotted: true, + validKeys: validFilters, + disableTypeConversions: true + }); } catch (e) { callback(e); return; diff --git a/lib/do_volume/do_create.js b/lib/do_volume/do_create.js new file mode 100644 index 0000000..9503a69 --- /dev/null +++ b/lib/do_volume/do_create.js @@ -0,0 +1,200 @@ +/* + * 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'); +var mod_volumes = require('../volumes'); + +function do_create(subcmd, opts, args, cb) { + var self = this; + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length !== 0) { + cb(new errors.UsageError('incorrect number of args')); + return; + } + + vasync.pipeline({arg: {cli: this.top}, funcs: [ + function validateVolumeSize(ctx, next) { + if (opts.size === undefined) { + next(); + return; + } + + try { + ctx.size = mod_volumes.parseVolumeSize(opts.size); + } catch (parseSizeErr) { + next(parseSizeErr); + return; + } + + next(); + }, + common.cliSetupTritonApi, + function createVolume(ctx, next) { + var createVolumeParams = { + type: 'tritonnfs', + name: opts.name, + network: opts.network, + size: ctx.size + }; + + if (opts.type) { + createVolumeParams.type = opts.type; + } + + self.top.tritonapi.createVolume(createVolumeParams, + function onRes(volCreateErr, volume) { + if (!volCreateErr && !opts.json) { + console.log('Creating volume %s (%s)', volume.name, + volume.id); + } + ctx.volume = volume; + next(volCreateErr); + }); + }, + function maybeWait(ctx, next) { + var distraction; + var waitTimeout = opts.wait_timeout === undefined ? + undefined : opts.wait_timeout * 1000; + + if (!opts.wait) { + next(); + return; + } + + if (process.stderr.isTTY && opts.wait.length > 1) { + distraction = distractions.createDistraction(opts.wait.length); + } + + self.top.tritonapi.cloudapi.waitForVolumeStates({ + id: ctx.volume.id, + states: ['ready', 'failed'], + timeout: waitTimeout + }, function onWaitDone(waitErr, volume) { + if (distraction) { + distraction.destroy(); + } + + if (waitErr) { + next(waitErr); + return; + } + + assert.object(volume, 'volume'); + + if (opts.json) { + console.log(JSON.stringify(volume)); + } else if (volume.state === 'ready') { + console.log('Created volume %s (%s)', volume.name, + volume.id); + } else { + next(new Error(format('failed to create volume %s (%s)', + volume.name, volume.id))); + return; + } + + next(); + }); + } + ]}, cb); +} + +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 and currently only supported type is ' + + '"tritonnfs".' + }, + { + names: ['size', 's'], + type: 'string', + helpArg: 'SIZE', + help: 'The size of the volume to create, in the form ' + + '``, e.g. `20G`. must be > 0. Supported ' + + 'units are `G` or `g` for gibibytes and `M` or `m` for mebibytes.' + + ' If a size is not specified, the newly created volume will have ' + + 'a default size corresponding to the smallest size available.', + completionType: 'tritonvolumesize' + }, + { + names: ['network', 'N'], + type: 'string', + helpArg: 'NETWORK', + help: 'A network (ID, name or short id) to which the newly created ' + + 'volume will be attached. By default, the newly created volume ' + + 'will be attached to the account\'s default fabric network.', + 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.' + }, + { + names: ['wait-timeout'], + type: 'positiveInteger', + help: 'The number of seconds to wait before timing out with an error.' + } +]; + +do_create.synopses = ['{{name}} {{cmd}} [OPTIONS]']; + +do_create.help = [ + /* BEGIN JSSTYLED */ + 'Create a volume.', + '', + '{{usage}}', + '', + '{{options}}', + '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..0c826e4 --- /dev/null +++ b/lib/do_volume/do_delete.js @@ -0,0 +1,157 @@ +/* + * 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 delete ...` + */ + +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_delete(subcmd, opts, args, cb) { + var self = this; + + if (opts.help) { + self.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length < 1) { + cb(new errors.UsageError('missing VOLUME arg(s)')); + return; + } + + var context = { + volumeIds: args, + cli: this.top + }; + + vasync.pipeline({arg: context, funcs: [ + common.cliSetupTritonApi, + function confirm(ctx, next) { + var promptMsg; + + if (opts.yes) { + next(); + return; + } + + if (ctx.volumeIds.length === 1) { + promptMsg = format('Delete volume %s? [y/n] ', + ctx.volumeIds[0]); + } else { + promptMsg = format('Delete %d volumes? [y/n] ', + ctx.volumeIds.length); + } + + common.promptYesNo({msg: promptMsg}, + function onPromptAnswered(answer) { + if (answer !== 'y') { + console.error('Aborting'); + /* + * Early abort signal. + */ + next(true); + } else { + next(); + } + }); + }, + function deleteVolumes(ctx, next) { + vasync.forEachParallel({ + func: function doDeleteVolume(volumeId, done) { + if (opts.wait === undefined) { + console.log('Deleting volume %s', volumeId); + } + + self.top.tritonapi.deleteVolume({ + id: volumeId, + wait: opts.wait && opts.wait.length > 0, + waitTimeout: opts.wait_timeout * 1000 + }, function onVolDeleted(volDelErr) { + if (!volDelErr) { + if (opts.wait !== undefined) { + console.log('Deleted volume %s', volumeId); + } + } else { + console.error('Error when deleting volume %s: %s', + volumeId, volDelErr); + } + + done(volDelErr); + }); + }, + inputs: ctx.volumeIds + }, next); + } + ]}, function onDone(err) { + if (err === true) { + /* + * Answered 'no' to confirmation to delete. + */ + err = null; + } + + if (err) { + cb(err); + return; + } + + cb(); + }); +} + +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.' + }, + { + names: ['wait-timeout'], + type: 'positiveInteger', + default: 120, + help: 'The number of seconds to wait before timing out with an error. ' + + 'The default is 120 seconds.' + }, + { + names: ['yes', 'y'], + type: 'bool', + help: 'Answer yes to confirmation to delete.' + } +]; + +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']; +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..9b16a82 --- /dev/null +++ b/lib/do_volume/do_list.js @@ -0,0 +1,151 @@ +/* + * 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 list ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var jsprim = require('jsprim'); +var tabula = require('tabula'); +var VError = require('verror'); + +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,age'; + +// columns default with -l +var columnsDefaultLong = 'id,name,size,type,state,created'; + +// sort default with -s +var sortDefault = 'created'; + +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.jsonPredFromKv(args, validFilters, 'and'); + } catch (e) { + callback(new VError(e, 'invalid filters')); + return; + } + } + + if (jsprim.deepEqual(filterPredicate, {})) { + filterPredicate = { 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) { + var now; + + if (listVolsErr) { + return callback(listVolsErr); + } + + if (opts.json) { + common.jsonStream(volumes); + } else { + now = new Date(); + for (var i = 0; i < volumes.length; i++) { + var created; + var volume = volumes[i]; + + created = new Date(volume.create_timestamp); + + volume.shortid = volume.id.split('-', 1)[0]; + volume.created = volume.create_timestamp; + volume.age = common.longAgo(created, now); + } + + 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] [FILTERS]']; + +do_list.help = [ + /* BEGIN JSSTYLED */ + 'List volumes.', + , + '{{usage}}', + '', + '{{options}}', + 'Filters:', + ' FIELD=VALUE Equality filter. Supported fields: name, type,', + ' size, and state', + '', + 'Fields (most are self explanatory, "*" indicates a field added client-side', + 'for convenience):', + ' shortid* A short ID prefix.', + ' age* Approximate time since created, e.g. 1y, 2w.', + '' + /* 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..645639f --- /dev/null +++ b/lib/do_volume/index.js @@ -0,0 +1,53 @@ +/* + * 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']; + +VolumeCLI.hidden = true; + +module.exports = VolumeCLI; diff --git a/lib/do_volumes.js b/lib/do_volumes.js new file mode 100644 index 0000000..0cf9fa3 --- /dev/null +++ b/lib/do_volumes.js @@ -0,0 +1,30 @@ +/* + * 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 ...`. + */ + +var targ = require('./do_volume/do_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".\n' + targ.help; +do_volumes.aliases = ['vols']; +do_volumes.hidden = true; +do_volumes.options = targ.options; +do_volumes.completionArgtypes = targ.completionArgtypes; +do_volumes.synopses = targ.synopses; + +module.exports = do_volumes; diff --git a/lib/tritonapi.js b/lib/tritonapi.js index 10db0f3..517eedb 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2016 Joyent, Inc. + * Copyright 2017 Joyent, Inc. */ /* BEGIN JSSTYLED */ @@ -113,6 +113,7 @@ var auth = require('smartdc-auth'); var EventEmitter = require('events').EventEmitter; var fs = require('fs'); var format = require('util').format; +var jsprim = require('jsprim'); var mkdirp = require('mkdirp'); var once = require('once'); var path = require('path'); @@ -122,6 +123,7 @@ var restifyBunyanSerializers = require('restify-clients/lib/helpers/bunyan').serializers; var tabula = require('tabula'); var vasync = require('vasync'); +var VError = require('verror'); var sshpk = require('sshpk'); var cloudapi = require('./cloudapi2'); @@ -184,6 +186,30 @@ function _stepInstId(arg, next) { } } +/** + * A function appropriate for `vasync.pipeline` funcs that takes a `arg.id` + * volume name, shortid or uuid, and determines the volume id (setting it + * as `arg.volId`). + */ +function _stepVolId(arg, next) { + assert.object(arg.client, 'arg.client'); + assert.string(arg.id, 'arg.id'); + + if (common.isUUID(arg.id)) { + arg.volId = arg.id; + next(); + } else { + arg.client.getVolume(arg.id, function onGetVolume(getVolErr, vol) { + if (getVolErr) { + next(getVolErr); + } else { + arg.volId = vol.id; + next(); + } + }); + } +} + /** * A function appropriate for `vasync.pipeline` funcs that takes a `arg.package` * package name, short id or uuid, and determines the package id (setting it @@ -2388,11 +2414,6 @@ TritonApi.prototype.rebootInstance = function rebootInstance(opts, cb) { function randrange(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } - function timeDiffMs(relativeTo) { - var diff = process.hrtime(relativeTo); - var ms = (diff[0] * 1e3) + (diff[1] / 1e6); // in milliseconds - return ms; - } vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [ _stepInstId, @@ -2472,7 +2493,8 @@ TritonApi.prototype.rebootInstance = function rebootInstance(opts, cb) { if (!theRecord) { if (opts.waitTimeout) { - var elapsedMs = timeDiffMs(startTime); + var elapsedMs = + common.monotonicTimeDiffMs(startTime); if (elapsedMs > opts.waitTimeout) { next(new errors.TimeoutError(format('timeout ' + 'waiting for instance %s reboot ' @@ -2676,6 +2698,249 @@ function _waitForInstanceUpdate(opts, cb) { setImmediate(poll); }; +/** + * Creates a volume according to the parameters in "params" and calls the + * function "cb" when done. + * + * @param {Object} params + * - {String} type: Required. The type of the volume to create. The only + * valid value for now is "tritonnfs". + * - {String} name: Optional. The name of the volume to create. If not + * provided, a name will be automatically generated. + * - {String} network: Optional. The network name, short id or id on which + * the newly created volume will be reachable. + * - {Number} size: Optional. The desired size of the volume in mebibytes. + * If no size if provided, the volume will be created with the smallest + * possible size as outputted by CloudAPI's ListVolumeSizes endpoint. + * @param {Function} cb: `function (err, volume)` + */ +TritonApi.prototype.createVolume = function createVolume(params, cb) { + assert.object(params, 'params'); + assert.string(params.type, 'params.type'); + assert.optionalString(params.name, 'params.name'); + assert.optionalString(params.network, 'params.network'); + assert.optionalNumber(params.size, 'params.size'); + assert.func(cb, 'cb'); + + var self = this; + var volumeCreated; + + vasync.pipeline({arg: {client: self}, funcs: [ + function doGetNetwork(arg, next) { + if (params.network === undefined || params.network === null) { + next(); + return; + } + + arg.client.getNetwork(params.network, + function onGetNetwork(getNetErr, net) { + if (getNetErr) { + next(getNetErr); + } else { + arg.networkId = net.id; + next(); + } + }); + }, + function doCreateVolume(arg, next) { + var createVolParams = jsprim.deepCopy(params); + if (arg.networkId) { + createVolParams.networks = [arg.networkId]; + } + + arg.client.cloudapi.createVolume(createVolParams, + function onVolumeCreated(volCreateErr, volume) { + volumeCreated = volume; + next(volCreateErr); + }); + } + ]}, function done(err) { + cb(err, volumeCreated); + }); +}; + +/** + * 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(id, cb) { + assert.string(id, 'id'); + assert.func(cb, 'cb'); + + var shortId; + var self = this; + var volume; + + vasync.pipeline({funcs: [ + function tryUuid(_, next) { + var uuid; + if (common.isUUID(id)) { + uuid = id; + } else { + shortId = common.normShortId(id); + if (shortId && common.isUUID(shortId)) { + // E.g. a >32-char docker volume ID normalized to a UUID. + uuid = shortId; + } else { + next(); + return; + } + } + + self.cloudapi.getVolume({id: uuid}, function (err, vol) { + if (err) { + if (err.restCode === 'ResourceNotFound') { + err = new errors.ResourceNotFoundError(err, + format('volume with id %s was not found', uuid)); + } else { + err = null; + } + } + volume = vol; + next(err); + }); + }, + + function tryName(_, next) { + if (volume !== undefined) { + next(); + return; + } + + self.cloudapi.listVolumes({ + predicate: JSON.stringify({ + and: [ + { ne: ['state', 'failed'] }, + { eq: ['name', id] } + ] + }) + }, function (listVolumesErr, volumes) { + var err; + + if (listVolumesErr) { + next(listVolumesErr); + return; + } + + assert.arrayOfObject(volumes, 'volumes'); + + if (volumes.length === 1) { + volume = volumes[0]; + } else if (volumes.length > 1) { + err = new errors.TritonError(format( + 'volume name "%s" is ambiguous: matches %d volumes', + id, volumes.length)); + } + + next(err); + }); + }, + + function tryShortId(_, next) { + if (volume !== undefined || !shortId) { + next(); + return; + } + + self.cloudapi.listVolumes({ + predicate: JSON.stringify({ + ne: ['state', 'failed'] + }) + }, function (listVolumesErr, volumes) { + var candidate; + var candidateIdx = 0; + var err; + var match; + + if (!listVolumesErr) { + for (candidateIdx in volumes) { + candidate = volumes[candidateIdx]; + if (candidate.id.slice(0, shortId.length) === shortId) { + if (match) { + err = (new errors.TritonError( + 'instance short id "%s" is ambiguous', + shortId)); + break; + } else { + match = candidate; + } + } + } + } + + volume = match; + next(err); + }); + } + ]}, function getVolDone(err) { + if (err || volume) { + cb(err, volume); + } else { + cb(new errors.ResourceNotFoundError(format( + 'no volume with id, name or short id "%s" was found', id))); + } + }); +}; + +/** + * Deletes 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. + * + * @param {Object} opts + * - {String} id: Required. The volume to delete's id, name or short ID. + * - {Boolean} wait: Optional. true if "cb" must be called once the volume + * is actually deleted, or deletion failed. If "false", "cb" will be + * called as soon as the deletion process is scheduled. + * - {Number} waitTimeout: Optional. if "wait" is true, represents the + * number of milliseconds after which to timeout (call `cb` with a + * timeout error) waiting. + * @param {Function} cb: `function (err)` + */ +TritonApi.prototype.deleteVolume = function deleteVolume(opts, cb) { + assert.string(opts.id, 'opts.id'); + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalNumber(opts.waitTimeout, 'opts.waitTimeout'); + assert.func(cb, 'cb'); + + var self = this; + var res; + + vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [ + _stepVolId, + + function doDelete(arg, next) { + self.cloudapi.deleteVolume(arg.volId, + function onVolDeleted(volDelErr, _, _res) { + res = _res; + next(volDelErr); + }); + }, + + function waitForVolumeDeleted(arg, next) { + if (!opts.wait) { + next(); + return; + } + self.cloudapi.waitForVolumeStates({ + id: arg.volId, + states: ['failed'], + timeout: opts.waitTimeout + }, function onVolumeStateReached(err) { + if (VError.hasCauseWithName(err, 'VolumeNotFoundError')) { + // volume is gone, that's not an error + next(); + return; + } + next(err); + }); + } + ]}, function onDeletionComplete(err) { + cb(err, null, res); + }); +}; + //---- exports module.exports = { diff --git a/lib/volumes.js b/lib/volumes.js new file mode 100644 index 0000000..d2c27b8 --- /dev/null +++ b/lib/volumes.js @@ -0,0 +1,68 @@ +/* + * 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) 2017, Joyent, Inc. + */ + +var assert = require('assert-plus'); + +function throwInvalidSize(size) { + assert.string(size, 'size'); + + throw new Error('size "' + size + '" is not a valid volume size'); +} + +/* + * Returns the number of MiBs (Mebibytes) represented by the string "size". That + * string has the following format: . The integer must be > 0. + * Unit format suffixes are 'G' or 'g' for gibibytes and 'M' or 'm' for + * mebibytes. + * + * Examples: + * - the strings '100m' and '100M' represent 100 mebibytes + * - the strings '100g' and '100G' represent 100 gibibytes + * + * If "size" is not a valid size string, an error is thrown. + */ +function parseVolumeSize(size) { + assert.string(size, 'size'); + + var MIBS_IN_GB = 1024; + + var MULTIPLIERS_TABLE = { + g: MIBS_IN_GB, + G: MIBS_IN_GB, + m: 1, + M: 1 + }; + + var multiplier; + var multiplierSymbol; + var baseValue; + + var matches = size.match(/^([1-9]\d*)(g|m|G|M)$/); + if (!matches) { + throwInvalidSize(size); + } + + multiplierSymbol = matches[2]; + if (multiplierSymbol) { + multiplier = MULTIPLIERS_TABLE[multiplierSymbol]; + } + + baseValue = Number(matches[1]); + + if (isNaN(baseValue) || multiplier === undefined) { + throwInvalidSize(size); + } + + return baseValue * multiplier; +} + +module.exports = { + parseVolumeSize: parseVolumeSize +}; \ No newline at end of file diff --git a/package.json b/package.json index e9d8da6..5b97d64 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "bunyan": "1.5.1", "cmdln": "4.1.2", "extsprintf": "1.0.2", - "getpass": "0.1.6", + "getpass": "0.1.6", + "jsprim": "1.4.0", "lomstream": "1.1.0", "mkdirp": "0.5.1", "once": "1.3.2", @@ -26,7 +27,7 @@ "strsplit": "1.0.0", "tabula": "1.9.0", "vasync": "1.6.3", - "verror": "1.6.0", + "verror": "1.10.0", "which": "1.2.4", "wordwrap": "1.0.0" }, 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-size.test.js b/test/integration/cli-volumes-size.test.js new file mode 100644 index 0000000..f89a3c0 --- /dev/null +++ b/test/integration/cli-volumes-size.test.js @@ -0,0 +1,103 @@ +/* + * 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) 2017, Joyent, Inc. + */ + +/* + * Test volume create command's size parameter. + */ + +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 FABRIC_NETWORKS = []; + +var testOpts = { + skip: !h.CONFIG.allowWriteActions +}; +test('triton volume create with non-default size...', testOpts, function (tt) { + var validVolumeName = + h.makeResourceName('node-triton-test-volume-create-non-default-' + + 'size'); + var validVolumeSize = '20g'; + var validVolumeSizeInMib = 20 * 1024; + + 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', '-y', '-w', validVolumeName].join(' '), + function onDelVolume(delVolErr, stdout, stderr) { + // If there was nothing to delete, this will fail so that's the + // normal case. Too bad we don't have a --force option. + t.end(); + }); + }); + + tt.test(' triton volume create volume with non-default size', + function (t) { + h.triton([ + 'volume', + 'create', + '--name', + validVolumeName, + '--size', + validVolumeSize, + '-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) { + var volume; + + t.equal(getVolErr, null, + 'Getting volume should not error'); + + volume = JSON.parse(stdout); + t.equal(volume.size, validVolumeSizeInMib, + 'volume size should be ' + validVolumeSizeInMib + + ', got: ' + volume.size); + t.end(); + }); + }); + + tt.test(' delete volume', function (t) { + h.safeTriton(t, ['volume', 'delete', '-y', '-w', validVolumeName], + function onDelVolume(delVolErr, stdout) { + 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 error'); + t.notEqual(stderr.indexOf('ResourceNotFound'), -1, + 'Getting volume ' + validVolumeName + 'should not find it'); + t.end(); + }); + }); +}); \ No newline at end of file diff --git a/test/integration/cli-volumes.test.js b/test/integration/cli-volumes.test.js new file mode 100644 index 0000000..ad7a1e4 --- /dev/null +++ b/test/integration/cli-volumes.test.js @@ -0,0 +1,251 @@ +/* + * 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 FABRIC_NETWORKS = []; + +var testOpts = { + skip: !h.CONFIG.allowWriteActions +}; +test('triton volume create ...', testOpts, function (tt) { + var currentVolume; + 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', '-y', '-w', validVolumeName].join(' '), + function onDelVolume(delVolErr, stdout, stderr) { + // If there was nothing to delete, this will fail so that's the + // normal case. Too bad we don't have a --force option. + 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.ok(volCreateErr, 'create should have failed' + + (volCreateErr ? '' : ', but succeeded')); + 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: size "' + + invalidSize + '" is not a valid volume size'; + 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 = 'no network with name or short id "' + + invalidNetwork + '" was found'; + + h.triton([ + 'volume', + 'create', + '--name', + volumeName, + '--network', + invalidNetwork + ].join(' '), function (volCreateErr, stdout, stderr) { + t.notEqual(stderr.indexOf(expectedErrMsg), -1, + 'stderr should include error message: ' + expectedErrMsg + + ', got: ' + volCreateErr); + 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', '-y', '-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 error'); + t.notEqual(stderr.indexOf('ResourceNotFound'), -1, + 'Getting volume ' + validVolumeName + 'should not find it'); + t.end(); + }); + }); + + // Test that we can create a volume with a valid fabric network and the + // volume ends up on that network. + + tt.test(' find fabric network', function (t) { + h.triton(['network', 'list', '-j'].join(' '), + function onGetNetworks(getNetworksErr, stdout, stderr) { + var resultsObj; + + t.ifErr(getNetworksErr, 'should succeed getting network list'); + + // turn the JSON lines into a JSON object + resultsObj = JSON.parse('[' + stdout.trim().replace(/\n/g, ',') + + ']'); + + t.ok(resultsObj.length > 0, + 'should find at least 1 network, found ' + + resultsObj.length); + + FABRIC_NETWORKS = resultsObj.filter(function fabricFilter(net) { + // keep only those networks that are marked as fabric=true + return (net.fabric === true); + }); + + t.ok(FABRIC_NETWORKS.length > 0, + 'should find at least 1 fabric network, found ' + + FABRIC_NETWORKS.length); + + t.end(); + }); + }); + + tt.test(' triton volume on fabric network', function (t) { + h.triton([ + 'volume', + 'create', + '--name', + 'node-triton-test-volume-create-fabric-network', + '--network', + FABRIC_NETWORKS[0].id, + '-w', + '-j' + ].join(' '), function (volCreateErr, stdout, stderr) { + t.ifErr(volCreateErr, 'volume creation should succeed'); + t.comment('stdout: ' + stdout); + t.comment('stderr: ' + stderr); + currentVolume = JSON.parse(stdout); + t.end(); + }); + }); + + tt.test(' check volume was created', function (t) { + h.safeTriton(t, ['volume', 'get', currentVolume.name], + function onGetVolume(getVolErr, stdout) { + var volumeObj; + + t.ifError(getVolErr, 'getting volume should succeed'); + + volumeObj = JSON.parse(stdout); + t.equal(volumeObj.networks[0], FABRIC_NETWORKS[0].id, + 'expect network to match fabric we passed'); + + t.end(); + }); + }); + + tt.test(' delete volume', function (t) { + h.triton(['volume', 'delete', '-y', '-w', currentVolume.name].join(' '), + function onDelVolume(delVolErr, stdout, stderr) { + t.ifError(delVolErr, 'deleting volume should succeed'); + t.end(); + }); + }); + +}); diff --git a/test/integration/helpers.js b/test/integration/helpers.js index 75b6de5..187ce2a 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'); @@ -488,6 +489,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 @@ -508,6 +517,7 @@ module.exports = { getResizeTestPkg: getResizeTestPkg, jsonStreamParse: jsonStreamParse, + makeResourceName: makeResourceName, printConfig: printConfig, ifErr: testcommon.ifErr diff --git a/test/unit/common.test.js b/test/unit/common.test.js index fe717b9..9625d2e 100644 --- a/test/unit/common.test.js +++ b/test/unit/common.test.js @@ -116,7 +116,7 @@ test('jsonStream', function (t) { t.end(); }); -test('kvToObj', function (t) { +test('objFromKeyValueArgs', function (t) { var arr = ['foo=1', 'bar=2', 'baz=3']; var o = { foo: '1', @@ -126,21 +126,77 @@ test('kvToObj', function (t) { var kv; // no valid parameter - kv = common.kvToObj(arr); + kv = common.objFromKeyValueArgs(arr, { + disableDotted: true, + disableTypeConversions: true + }); + t.deepEqual(kv, o); // valid parameters - kv = common.kvToObj(arr, ['foo', 'bar', 'baz']); + kv = common.objFromKeyValueArgs(arr, { + validKeys: ['foo', 'bar', 'baz'], + disableDotted: true, + disableTypeConversions: true + }); + t.deepEqual(kv, o); // invalid parameters t.throws(function () { - common.kvToObj(arr, ['uh-oh']); + common.objFromKeyValueArgs(arr, { + validKeys: ['uh-oh'], + disableDotted: true, + disableTypeConversions: true + }); }); t.end(); }); +test('objFromKeyValueArgs failOnEmptyValue', function (t) { + var arr = ['foo=']; + var err; + + try { + common.objFromKeyValueArgs(arr, { + failOnEmptyValue: true + }); + } catch (e) { + err = e; + } + + t.ok(err); + + /* + * By default, failOnEmptyValue is not set, so the following should not + * throw an error. + */ + err = null; + try { + common.objFromKeyValueArgs(arr); + } catch (e) { + err = e; + } + t.equal(err, null); + + /* + * Explicitly setting failOnEmptyValue to false should not throw an error + * when passing a key/value with an empty value. + */ + err = null; + try { + common.objFromKeyValueArgs(arr, { + failOnEmptyValue: false + }); + } catch (e) { + err = e; + } + t.equal(err, null); + + t.end(); +}); + test('longAgo', function (t) { var la = common.longAgo; var now = new Date(); diff --git a/test/unit/parseVolumeSize.test.js b/test/unit/parseVolumeSize.test.js new file mode 100644 index 0000000..024aa81 --- /dev/null +++ b/test/unit/parseVolumeSize.test.js @@ -0,0 +1,92 @@ +/* + * 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) 2017, Joyent, Inc. + */ + +/* + * Unit tests for `parseVolumeSize` used by `triton volume ...`. + */ + +var assert = require('assert-plus'); +var test = require('tape'); + +var parseVolumeSize = require('../../lib/volumes').parseVolumeSize; + +test('parseVolumeSize', function (tt) { + tt.test('parsing invalid sizes', function (t) { + var invalidVolumeSizes = [ + 'foo', + '0', + '-42', + '-42m', + '-42g', + '', + '42Gasdf', + '42gasdf', + '42asdf', + 'asdf42G', + 'asdf42g', + 'asdf42', + '042g', + '042G', + '042', + 0, + 42, + -42, + 42.1, + -42.1, + undefined, + null, + {} + ]; + + invalidVolumeSizes.forEach(function parse(invalidVolumeSize) { + var parseErr; + + try { + parseVolumeSize(invalidVolumeSize); + } catch (err) { + parseErr = err; + } + + t.ok(parseErr, 'parsing invalid volume size: ' + invalidVolumeSize + + ' should throw'); + }); + + t.end(); + }); + + tt.test('parsing valid sizes', function (t) { + var validVolumeSizes = [ + {input: '42m', expectedOutput: 42}, + {input: '42M', expectedOutput: 42}, + {input: '42g', expectedOutput: 42 * 1024}, + {input: '42G', expectedOutput: 42 * 1024} + ]; + + validVolumeSizes.forEach(function parse(validVolumeSize) { + var parseErr; + var volSizeInMebibytes; + + try { + volSizeInMebibytes = parseVolumeSize(validVolumeSize.input); + } catch (err) { + parseErr = err; + } + + t.ifErr(parseErr, 'parsing valid volume size: ' + + validVolumeSize.input + ' should not throw'); + t.equal(validVolumeSize.expectedOutput, volSizeInMebibytes, + 'parsed volume size for "' + validVolumeSize.input + '" ' + + 'should equal to ' + validVolumeSize.expectedOutput + + ' mebibytes'); + }); + + t.end(); + }); +}); \ No newline at end of file