Compare commits

...
This repository has been archived on 2020-01-20. You can view files and clone it, but cannot push or open issues or pull requests.

15 Commits

Author SHA1 Message Date
Josh Wilsdon
0a65be8239 VOLAPI-49 DELETE should delete (add support in node-triton) 2017-05-24 15:24:35 -07:00
Josh Wilsdon
471ea6f3fe PUBAPI-1376 Create volume errors out when the network parameter is passed
Reviewed By: Julien Gilli <julien.gilli@joyent.com>
2017-05-18 11:32:35 -07:00
Julien Gilli
348db1ebcc make unit not optional in parseVolumeSize 2017-04-05 15:19:46 -07:00
Julien Gilli
d4dbff084e address comments from latest code review 2017-04-05 14:40:14 -07:00
Julien Gilli
cb725eb587 fix parseVolumeSize comments doc according to latest changes 2017-04-03 18:56:26 -07:00
Julien Gilli
c68c975d88 changes according to latest code review 2017-04-03 18:11:06 -07:00
Julien Gilli
43620ff56f make triton volume feature hidden 2017-04-03 14:10:30 -07:00
Julien Gilli
f00d1bd9c0 changes according to latest code review from Trent 2017-03-23 13:12:24 -07:00
Julien Gilli
cd8d6a0658 fix tests after adding support for delete volume confirmation 2017-03-22 18:19:32 -07:00
Julien Gilli
984c692d2f fix handling of volume list filters 2017-03-22 18:19:02 -07:00
Julien Gilli
00f92d67a5 more changes according to discussion after initial code review 2017-03-22 16:54:36 -07:00
Julien Gilli
08b7dd088f fix current volumes tests suite 2017-03-21 14:43:39 -07:00
Julien Gilli
c152fa77e4 more changes according to first code review 2017-03-20 18:56:51 -07:00
Julien Gilli
d2fce639dd changes according to code review 2017-03-08 17:58:04 -08:00
Julien Gilli
679157d453 joyent/node-triton#173 Add support for listing and getting triton nfs volumes
joyent/node-triton#174 Add support for creating triton nfs volumes
joyent/node-triton#175 Add support for deleting triton NFS volumes
2017-03-07 12:46:31 -08:00
17 changed files with 1506 additions and 27 deletions

View File

@ -7,6 +7,21 @@ Known issues:
## not yet released ## not yet released
## 5.2.0
- 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.
- [joyent/node-triton#183] `triton profile create` will no longer use ANSI - [joyent/node-triton#183] `triton profile create` will no longer use ANSI
codes for styling if stdout isn't a TTY. codes for styling if stdout isn't a TTY.

View File

@ -695,6 +695,9 @@ CLI.prototype.do_cloudapi = require('./do_cloudapi');
CLI.prototype.do_badger = require('./do_badger'); CLI.prototype.do_badger = require('./do_badger');
CLI.prototype.do_rbac = require('./do_rbac'); CLI.prototype.do_rbac = require('./do_rbac');
// Volumes
CLI.prototype.do_volumes = require('./do_volumes');
CLI.prototype.do_volume = require('./do_volume');
//---- mainline //---- mainline

View File

@ -5,7 +5,7 @@
*/ */
/* /*
* Copyright 2015 Joyent, Inc. * Copyright 2017 Joyent, Inc.
* *
* Client library for the SmartDataCenter Cloud API (cloudapi). * Client library for the SmartDataCenter Cloud API (cloudapi).
* http://apidocs.joyent.com/cloudapi/ * http://apidocs.joyent.com/cloudapi/
@ -2275,7 +2275,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 // --- Exports

View File

@ -125,6 +125,40 @@ function jsonStream(arr, stream) {
}); });
} }
/**
* Parses the string "filterComponent" of the form 'key=value' and returns an
* object that represents it with the form {'key': 'value'}. If "key"" in the
* "filterComponent" string is not included in the list "validKeys", it throws
* an error. It also throws an error if the string "filterComponent" is
* malformed.
*
* @param {String} filterComponent
* @param {arrayOfString} validKeys: Optional
*/
function _parseFilterKeyAndValue(filterComponent, validKeys) {
assert.string(filterComponent, 'filterComponent');
assert.optionalArrayOfString(validKeys, 'validKeys');
var idx = filterComponent.indexOf('=');
if (idx === -1) {
throw new errors.UsageError(format(
'invalid filter: "%s" (must be of the form "field=value")',
filterComponent));
}
var k = filterComponent.slice(0, idx);
var v = filterComponent.slice(idx + 1);
if (validKeys && validKeys.indexOf(k) === -1) {
throw new errors.UsageError(format(
'invalid filter name: "%s" (must be one of "%s")',
k, validKeys.join('", "')));
}
return {
key: k,
value: v
};
}
/** /**
* given an array of key=value pairs, break them into an object * given an array of key=value pairs, break them into an object
* *
@ -138,24 +172,55 @@ function kvToObj(kvs, valid) {
assert.optionalArrayOfString(valid, 'valid'); assert.optionalArrayOfString(valid, 'valid');
var o = {}; var o = {};
var parsedKeyValue;
for (var i = 0; i < kvs.length; i++) { for (var i = 0; i < kvs.length; i++) {
var kv = kvs[i]; parsedKeyValue = _parseFilterKeyAndValue(kvs[i], valid);
var idx = kv.indexOf('='); o[parsedKeyValue.key] = parsedKeyValue.value;
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;
} }
return o; 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} validKeys (optional) - an array 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 jsonPredFromKv(kvs, validKeys, compositionType) {
assert.arrayOfString(kvs, 'kvs');
assert.arrayOfString(validKeys, 'validKeys');
assert.string(compositionType, 'string');
assert.ok(compositionType === 'or' || compositionType === 'and',
'compositionType');
var predicate = {};
var parsedKeyValue;
if (kvs.length === 0) {
return predicate;
}
if (kvs.length === 1) {
parsedKeyValue = _parseFilterKeyAndValue(kvs[0], validKeys);
predicate.eq = [parsedKeyValue.key, parsedKeyValue.value];
} else {
predicate[compositionType] = [];
for (var i = 0; i < kvs.length; i++) {
parsedKeyValue = _parseFilterKeyAndValue(kvs[i], validKeys);
predicate[compositionType].push({
eq: [parsedKeyValue.key, parsedKeyValue.value]
});
}
}
return predicate;
}
/** /**
* return how long ago something happened * return how long ago something happened
* *
@ -1079,6 +1144,25 @@ function objFromKeyValueArgs(args, opts)
return obj; 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;
}
//---- exports //---- exports
@ -1115,6 +1199,8 @@ module.exports = {
execPlus: execPlus, execPlus: execPlus,
deepEqual: deepEqual, deepEqual: deepEqual,
tildeSync: tildeSync, tildeSync: tildeSync,
objFromKeyValueArgs: objFromKeyValueArgs objFromKeyValueArgs: objFromKeyValueArgs,
jsonPredFromKv: jsonPredFromKv,
monotonicTimeDiffMs: monotonicTimeDiffMs
}; };
// vim: set softtabstop=4 shiftwidth=4: // vim: set softtabstop=4 shiftwidth=4:

206
lib/do_volume/do_create.js Normal file
View File

@ -0,0 +1,206 @@
/*
* 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 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.id);
}
nextNet(getNetErr);
});
}
}, next);
},
function createVolume(ctx, next) {
var createVolumeParams = {
type: 'tritonnfs',
name: opts.name,
networks: ctx.networks,
size: ctx.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) {
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();
}
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));
}
}
]}, 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 is "tritonnfs".'
},
{
names: ['size', 's'],
type: 'string',
helpArg: 'SIZE',
help: 'The size of the volume to create, in the form ' +
'`<integer><unit>`, e.g. `20G`. <integer> must be > 0. Supported ' +
'units are `G` or `g` for gibibytes and `M` or `m` for mebibytes.',
completionType: 'tritonvolumesize'
},
{
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.'
},
{
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;

157
lib/do_volume/do_delete.js Normal file
View File

@ -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', 'none'];
do_delete.aliases = ['rm'];
module.exports = do_delete;

79
lib/do_volume/do_get.js Normal file
View File

@ -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;

137
lib/do_volume/do_list.js Normal file
View File

@ -0,0 +1,137 @@
/*
* 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 format = require('util').format;
var jsprim = require('jsprim');
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,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(e);
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 volume = volumes[i];
volume.shortid = volume.id.split('-', 1)[0];
volume.created = new Date(volume.create_timestamp);
volume.age = common.longAgo(volume.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}}'
/* END JSSTYLED */
].join('\n');
do_list.aliases = ['ls'];
module.exports = do_list;

53
lib/do_volume/index.js Normal file
View File

@ -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;

30
lib/do_volumes.js Normal file
View File

@ -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;

View File

@ -5,7 +5,7 @@
*/ */
/* /*
* Copyright 2016 Joyent, Inc. * Copyright 2017 Joyent, Inc.
*/ */
/* BEGIN JSSTYLED */ /* BEGIN JSSTYLED */
@ -122,6 +122,7 @@ var restifyBunyanSerializers =
require('restify-clients/lib/helpers/bunyan').serializers; require('restify-clients/lib/helpers/bunyan').serializers;
var tabula = require('tabula'); var tabula = require('tabula');
var vasync = require('vasync'); var vasync = require('vasync');
var verror = require('verror');
var sshpk = require('sshpk'); var sshpk = require('sshpk');
var cloudapi = require('./cloudapi2'); var cloudapi = require('./cloudapi2');
@ -184,6 +185,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` * 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 * package name, short id or uuid, and determines the package id (setting it
@ -2313,11 +2338,6 @@ TritonApi.prototype.rebootInstance = function rebootInstance(opts, cb) {
function randrange(min, max) { function randrange(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min; 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: [ vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [
_stepInstId, _stepInstId,
@ -2398,7 +2418,8 @@ TritonApi.prototype.rebootInstance = function rebootInstance(opts, cb) {
if (!theRecord) { if (!theRecord) {
if (opts.waitTimeout) { if (opts.waitTimeout) {
var elapsedMs = timeDiffMs(startTime); var elapsedMs =
common.monotonicTimeDiffMs(startTime);
if (elapsedMs > opts.waitTimeout) { if (elapsedMs > opts.waitTimeout) {
next(new errors.TimeoutError(format('timeout ' next(new errors.TimeoutError(format('timeout '
+ 'waiting for instance %s reboot ' + 'waiting for instance %s reboot '
@ -2602,6 +2623,124 @@ function _waitForInstanceUpdate(opts, cb) {
setImmediate(poll); 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({ 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)));
}
});
}
};
/**
* 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, 'VOLUME_NOT_FOUNDError')) {
// volume is gone, that's not an error
next();
return;
}
next(err);
});
}
]}, function onDeletionComplete(err) {
cb(err, null, res);
});
};
//---- exports //---- exports
module.exports = { module.exports = {

68
lib/volumes.js Normal file
View File

@ -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: <integer><unit>. 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
};

View File

@ -1,7 +1,7 @@
{ {
"name": "triton", "name": "triton",
"description": "Joyent Triton CLI and client (https://www.joyent.com/triton)", "description": "Joyent Triton CLI and client (https://www.joyent.com/triton)",
"version": "5.1.0", "version": "5.2.0",
"author": "Joyent (joyent.com)", "author": "Joyent (joyent.com)",
"homepage": "https://github.com/joyent/node-triton", "homepage": "https://github.com/joyent/node-triton",
"dependencies": { "dependencies": {
@ -12,6 +12,7 @@
"cmdln": "4.1.2", "cmdln": "4.1.2",
"extsprintf": "1.0.2", "extsprintf": "1.0.2",
"getpass": "0.1.6", "getpass": "0.1.6",
"jsprim": "^1.4.0",
"lomstream": "1.1.0", "lomstream": "1.1.0",
"mkdirp": "0.5.1", "mkdirp": "0.5.1",
"once": "1.3.2", "once": "1.3.2",
@ -26,7 +27,7 @@
"strsplit": "1.0.0", "strsplit": "1.0.0",
"tabula": "1.9.0", "tabula": "1.9.0",
"vasync": "1.6.3", "vasync": "1.6.3",
"verror": "1.6.0", "verror": "1.10.0",
"which": "1.2.4", "which": "1.2.4",
"wordwrap": "1.0.0" "wordwrap": "1.0.0"
}, },

View File

@ -97,7 +97,12 @@ var subs = [
['rbac image-role-tags'], ['rbac image-role-tags'],
['rbac network-role-tags'], ['rbac network-role-tags'],
['rbac package-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 // --- Tests

View File

@ -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 =
'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', '-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'
].join(' '), function (volCreateErr, stdout, stderr) {
t.ifErr(volCreateErr, 'volume creation should succeed');
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();
});
});
});

View File

@ -12,9 +12,10 @@
* Test helpers for the integration tests * Test helpers for the integration tests
*/ */
var error = console.error;
var assert = require('assert-plus'); var assert = require('assert-plus');
var error = console.error;
var f = require('util').format; var f = require('util').format;
var os = require('os');
var path = require('path'); var path = require('path');
var tabula = require('tabula'); 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 // --- exports
@ -373,6 +382,7 @@ module.exports = {
getTestPkg: getTestPkg, getTestPkg: getTestPkg,
getResizeTestPkg: getResizeTestPkg, getResizeTestPkg: getResizeTestPkg,
jsonStreamParse: jsonStreamParse, jsonStreamParse: jsonStreamParse,
makeResourceName: makeResourceName,
printConfig: printConfig, printConfig: printConfig,
ifErr: testcommon.ifErr ifErr: testcommon.ifErr

View File

@ -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();
});
});