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.

17 Commits

Author SHA1 Message Date
Josh Wilsdon 84ca33d6ea add name per review feedback 2017-05-18 10:29:18 -07:00
Josh Wilsdon e7778a8f5d apply changes suggested through review, add test 2017-05-17 20:15:28 -07:00
Josh Wilsdon bc80ea4426 missing semicolon 2017-05-16 17:26:27 -07:00
Josh Wilsdon 0d271cb7e2 update to pass networks correctly when calling CreateVolume 2017-05-16 17:23:16 -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 1555 additions and 25 deletions

View File

@ -7,6 +7,21 @@ Known issues:
## 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
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_rbac = require('./do_rbac');
// Volumes
CLI.prototype.do_volumes = require('./do_volumes');
CLI.prototype.do_volume = require('./do_volume');
//---- mainline

View File

@ -5,7 +5,7 @@
*/
/*
* Copyright 2015 Joyent, Inc.
* Copyright 2017 Joyent, Inc.
*
* Client library for the SmartDataCenter Cloud API (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

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
*
@ -138,24 +172,55 @@ function kvToObj(kvs, valid) {
assert.optionalArrayOfString(valid, 'valid');
var o = {};
var parsedKeyValue;
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;
parsedKeyValue = _parseFilterKeyAndValue(kvs[i], valid);
o[parsedKeyValue.key] = parsedKeyValue.value;
}
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
*
@ -1079,6 +1144,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;
}
//---- exports
@ -1115,6 +1199,8 @@ module.exports = {
execPlus: execPlus,
deepEqual: deepEqual,
tildeSync: tildeSync,
objFromKeyValueArgs: objFromKeyValueArgs
objFromKeyValueArgs: objFromKeyValueArgs,
jsonPredFromKv: jsonPredFromKv,
monotonicTimeDiffMs: monotonicTimeDiffMs
};
// 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;

206
lib/do_volume/do_delete.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 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 deleteVolume(volumeName, opts, cb) {
assert.string(volumeName, 'volumeName');
assert.object(opts, 'opts');
assert.object(opts.tritonapi, 'opts.tritonapi');
assert.func(cb, 'cb');
var tritonapi = opts.tritonapi;
vasync.pipeline({funcs: [
function getVolume(ctx, next) {
tritonapi.getVolume(volumeName,
function onGetVolume(getVolErr, volume) {
if (!getVolErr) {
ctx.volume = volume;
}
next(getVolErr);
});
},
function doDeleteVolume(ctx, next) {
assert.object(ctx.volume, 'ctx.volume');
tritonapi.cloudapi.deleteVolume(ctx.volume.id,
next);
},
function waitForVolumeStates(ctx, next) {
assert.object(ctx.volume, 'ctx.volume');
var distraction;
var volumeId = ctx.volume.id;
if (!opts.wait) {
next();
return;
}
distraction = distractions.createDistraction(opts.wait.length);
tritonapi.cloudapi.waitForVolumeStates({
id: volumeId,
states: ['deleted', 'failed']
}, function onWaitDone(waitErr, volume) {
distraction.destroy();
next(waitErr);
});
}
], arg: {}}, cb);
}
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;

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

@ -0,0 +1,142 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Copyright 2016 Joyent, Inc.
*
* `triton 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 = {
and: [
{ ne: ['state', 'deleted']},
{ ne: ['state', 'failed']}
]
};
}
if (filterPredicate) {
listOpts = {
predicate: JSON.stringify(filterPredicate)
};
}
common.cliSetupTritonApi({cli: this.top}, function onSetup(setupErr) {
if (setupErr) {
callback(setupErr);
}
self.top.tritonapi.cloudapi.listVolumes(listOpts,
function onRes(listVolsErr, volumes, res) {
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

@ -184,6 +184,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
@ -2313,11 +2337,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,
@ -2398,7 +2417,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 '
@ -2602,6 +2622,122 @@ function _waitForInstanceUpdate(opts, cb) {
setImmediate(poll);
};
/**
* Get a volume by ID, exact name, or short ID, in that order.
*
* If there is more than one volume with that name, then this errors out.
*/
TritonApi.prototype.getVolume = function getVolume(name, cb) {
assert.string(name, 'name');
assert.func(cb, 'cb');
if (common.isUUID(name)) {
this.cloudapi.getVolume({id: name}, function (err, pkg) {
if (err) {
if (err.restCode === 'ResourceNotFound') {
err = new errors.ResourceNotFoundError(err,
format('volume with id %s was not found', name));
}
cb(err);
} else {
cb(null, pkg);
}
});
} else {
this.cloudapi.listVolumes({
predicate: JSON.stringify({
and: [
{ ne: ['state', 'deleted']},
{ ne: ['state', 'failed']}
]
})
}, function (err, volumes) {
if (err) {
return cb(err);
}
var nameMatches = [];
var shortIdMatches = [];
for (var i = 0; i < volumes.length; i++) {
var volume = volumes[i];
if (volume.name === name) {
nameMatches.push(volume);
}
if (volume.id.slice(0, 8) === name) {
shortIdMatches.push(volume);
}
}
if (nameMatches.length === 1) {
cb(null, nameMatches[0]);
} else if (nameMatches.length > 1) {
cb(new errors.TritonError(format(
'volume name "%s" is ambiguous: matches %d volumes',
name, nameMatches.length)));
} else if (shortIdMatches.length === 1) {
cb(null, shortIdMatches[0]);
} else if (shortIdMatches.length === 0) {
cb(new errors.ResourceNotFoundError(format(
'no volume with name or short id "%s" was found', name)));
} else {
cb(new errors.ResourceNotFoundError(
format('no volume with name "%s" was found '
+ 'and "%s" is an ambiguous short id', name)));
}
});
}
};
/**
* 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: ['deleted', 'failed'],
timeout: opts.waitTimeout
}, next);
}
]}, function onDeletionComplete(err) {
cb(err, null, res);
});
};
//---- 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",
"description": "Joyent Triton CLI and client (https://www.joyent.com/triton)",
"version": "5.1.0",
"version": "5.2.0",
"author": "Joyent (joyent.com)",
"homepage": "https://github.com/joyent/node-triton",
"dependencies": {
@ -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",

View File

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

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 errorr');
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
*/
var error = console.error;
var assert = require('assert-plus');
var error = console.error;
var f = require('util').format;
var os = require('os');
var path = require('path');
var tabula = require('tabula');
@ -359,6 +360,14 @@ function printConfig(t) {
});
}
/*
* Returns a string that represents a unique resource name for the host on which
* this function is called.
*/
function makeResourceName(prefix) {
assert.string(prefix, 'prefix');
return prefix + '-' + os.hostname();
}
// --- exports
@ -373,6 +382,7 @@ module.exports = {
getTestPkg: getTestPkg,
getResizeTestPkg: getResizeTestPkg,
jsonStreamParse: jsonStreamParse,
makeResourceName: makeResourceName,
printConfig: printConfig,
ifErr: testcommon.ifErr

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