diff --git a/CHANGES.md b/CHANGES.md index aad11a1..ea6f9a7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,34 @@ # node-triton changelog -## 4.4.2 (not yet released) +## 4.5.1 (not yet released) (nothing yet) +## 4.5.0 + +- #88 'triton inst tag ...' for managing instance tags. + + +## 4.4.4 + +- #90 Update sshpk and smartdc-auth to attempt to deal with multiple package + inter-deps. + + +## 4.4.3 + +- #86 Ensure `triton profile ls` and `triton profile set-current` work + when there is no current profile. + + +## 4.4.2 + +- Support `triton.createClient(...)` creation without requiring a + `configDir`. Basically this then fallsback to a `TritonApi` with the default + config. + + ## 4.4.1 - #83, #84 Fix running `triton` on Windows. diff --git a/examples/example-list-instances.js b/examples/example-list-instances.js new file mode 100755 index 0000000..1f8c272 --- /dev/null +++ b/examples/example-list-instances.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Example using cloudapi2.js to call cloudapi's ListMachines endpoint. + * + * Usage: + * ./example-list-images.js | bunyan + */ + +var p = console.log; +var bunyan = require('bunyan'); +var triton = require('../'); // typically `require('triton');` + + +var URL = process.env.SDC_URL || 'https://us-sw-1.api.joyent.com'; +var ACCOUNT = process.env.SDC_ACCOUNT || 'bob'; +var KEY_ID = process.env.SDC_KEY_ID || 'b4:f0:b4:6c:18:3b:44:63:b4:4e:58:22:74:43:d4:bc'; + + +var log = bunyan.createLogger({ + name: 'test-list-instances', + level: process.env.LOG_LEVEL || 'trace' +}); + +/* + * More details on `createClient` options here: + * https://github.com/joyent/node-triton/blob/master/lib/index.js#L18-L61 + * For example, if you want to use an existing `triton` CLI profile, you can + * pass that profile name in. + */ +var client = triton.createClient({ + log: log, + profile: { + url: URL, + account: ACCOUNT, + keyId: KEY_ID + } +}); +// TODO: Eventually the top-level TritonApi will have `.listInstances()` to use. +client.cloudapi.listMachines(function (err, insts) { + client.close(); // Remember to close the client to close TCP conn. + if (err) { + console.error('listInstances err:', err); + } else { + console.log(JSON.stringify(insts, null, 4)); + } +}); \ No newline at end of file diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index eea7186..a7e16d7 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -264,7 +264,7 @@ CloudApi.prototype._passThrough = function _passThrough(endpoint, opts, cb) { assert.func(cb, 'cb'); var p = this._path(endpoint, opts); - this._request(p, function (err, req, res, body) { + this._request({path: p}, function (err, req, res, body) { /* * Improve this kind of error message: * @@ -939,6 +939,119 @@ CloudApi.prototype.machineAudit = function machineAudit(id, cb) { }); }; +// --- machine tags + +/** + * + * + * @param {Object} opts: + * - @param {UUID} id: The machine UUID. + * @param {Function} cb - `function (err, tags, res)` + */ +CloudApi.prototype.listMachineTags = function listMachineTags(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + var endpoint = format('/%s/machines/%s/tags', this.account, opts.id); + this._passThrough(endpoint, {}, cb); +}; + +/** + * + * + * @param {Object} opts: + * - @param {UUID} id: The machine UUID. Required. + * - @param {UUID} tag: The tag name. Required. + * @param {Function} cb - `function (err, value, res)` + * On success, `value` is the tag value *as a string*. See note above. + */ +CloudApi.prototype.getMachineTag = function getMachineTag(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.string(opts.tag, 'opts.tag'); + assert.func(cb, 'cb'); + + this._request({ + path: format('/%s/machines/%s/tags/%s', this.account, opts.id, + encodeURIComponent(opts.tag)) + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + +/** + * + * + * @param {Object} opts: + * - @param {UUID} id: The machine UUID. Required. + * - @param {Object} tags: The tag name/value pairs. + * @param {Function} cb - `function (err, tags, res)` + * On success, `tags` is the updated set of instance tags. + */ +CloudApi.prototype.addMachineTags = function addMachineTags(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.object(opts.tags, 'opts.tags'); + assert.func(cb, 'cb'); + + // TODO: should this strictly guard on opts.tags types? + + this._request({ + method: 'POST', + path: format('/%s/machines/%s/tags', this.account, opts.id), + data: opts.tags + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + +/** + * + * + * @param {Object} opts: + * - @param {UUID} id: The machine UUID. Required. + * - @param {Object} tags: The tag name/value pairs. + * @param {Function} cb - `function (err, tags, res)` + * On success, `tags` is the updated set of instance tags. + */ +CloudApi.prototype.replaceMachineTags = function replaceMachineTags(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.object(opts.tags, 'opts.tags'); + assert.func(cb, 'cb'); + + // TODO: should this strictly guard on opts.tags types? + + this._request({ + method: 'PUT', + path: format('/%s/machines/%s/tags', this.account, opts.id), + data: opts.tags + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + +/** + * + * + * @param {Object} opts: + * - @param {UUID} id: The machine UUID. Required. + * @param {Function} cb - `function (err, res)` + */ +CloudApi.prototype.deleteMachineTags = function deleteMachineTags(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + this._request({ + method: 'DELETE', + path: format('/%s/machines/%s/tags', this.account, opts.id) + }, function (err, req, res) { + cb(err, res); + }); +}; + // --- snapshots @@ -1136,7 +1249,6 @@ function createFirewallRule(opts, cb) { }); }; - /** * Lists all your Firewall Rules. * @@ -1252,7 +1364,7 @@ function disableFirewallRule(id, cb) { /** - * + * Remove a Firewall Rule. * * @param {Object} opts (object) * - {String} id (required) for your firewall. @@ -1273,6 +1385,31 @@ function deleteFirewallRule(opts, cb) { }; +/** + * + * + * @param {Object} opts: + * - @param {UUID} id: The machine UUID. Required. + * - @param {String} tag: The tag name. Required. + * @param {Function} cb - `function (err, res)` + */ +CloudApi.prototype.deleteMachineTag = function deleteMachineTag(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.string(opts.tag, 'opts.tag'); + assert.ok(opts.tag, 'opts.tag cannot be empty'); + assert.func(cb, 'cb'); + + this._request({ + method: 'DELETE', + path: format('/%s/machines/%s/tags/%s', this.account, opts.id, + encodeURIComponent(opts.tag)) + }, function (err, req, res) { + cb(err, res); + }); +}; + + /** * Lists all the Firewall Rules affecting a given machine. * diff --git a/lib/config.js b/lib/config.js index bd56add..9517f3c 100644 --- a/lib/config.js +++ b/lib/config.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2015 Joyent, Inc. + * Copyright 2016 Joyent, Inc. */ /* @@ -79,25 +79,35 @@ function configPathFromDir(configDir) { * * This includes some internal data on keys with a leading underscore: * _defaults the defaults.json object - * _user the "user" config.json object - * _configDir the user config dir + * _configDir the user config dir (if one is provided) + * _user the "user" config.json object (if exists) * + * @param opts.configDir {String} Optional. A base dir for TritonApi config. * @returns {Object} The loaded config. */ function loadConfig(opts) { assert.object(opts, 'opts'); - assert.string(opts.configDir, 'opts.configDir'); + assert.optionalString(opts.configDir, 'opts.configDir'); - var configDir = common.tildeSync(opts.configDir); - var configPath = configPathFromDir(configDir); + var configDir; + var configPath; + if (opts.configDir) { + configDir = common.tildeSync(opts.configDir); + configPath = configPathFromDir(configDir); + } var c = fs.readFileSync(DEFAULTS_PATH, 'utf8'); var _defaults = JSON.parse(c); var config = JSON.parse(c); - if (fs.existsSync(configPath)) { + if (configPath && fs.existsSync(configPath)) { c = fs.readFileSync(configPath, 'utf8'); - var _user = JSON.parse(c); - var userConfig = JSON.parse(c); + try { + var _user = JSON.parse(c); + var userConfig = JSON.parse(c); + } catch (userConfigParseErr) { + throw new errors.ConfigError( + format('"%s" is invalid JSON', configPath)); + } if (typeof (userConfig) !== 'object' || Array.isArray(userConfig)) { throw new errors.ConfigError( format('"%s" is not an object', configPath)); @@ -121,7 +131,9 @@ function loadConfig(opts) { config._user = _user; } config._defaults = _defaults; - config._configDir = configDir; + if (configDir) { + config._configDir = configDir; + } return config; } @@ -304,9 +316,14 @@ function loadProfile(opts) { assert.optionalString(opts.configDir, 'opts.configDir'); if (opts.name === 'env') { - return _loadEnvProfile(); + var envProfile = _loadEnvProfile(); + if (!envProfile) { + throw new errors.ConfigError('could not load "env" profile ' + + '(missing TRITON_*, or SDC_*, environment variables)'); + } + return envProfile; } else if (!opts.configDir) { - throw new errors.TritonError( + throw new errors.ConfigError( 'cannot load profiles (other than "env") without `opts.configDir`'); } else { var profilePath = path.resolve( diff --git a/lib/do_instance/do_create.js b/lib/do_instance/do_create.js index f326407..8b6b0ac 100644 --- a/lib/do_instance/do_create.js +++ b/lib/do_instance/do_create.js @@ -49,7 +49,7 @@ function do_create(subcmd, opts, args, cb) { }); }, function loadTags(ctx, next) { - mat.tagsFromOpts(opts, log, function (err, tags) { + mat.tagsFromCreateOpts(opts, log, function (err, tags) { if (err) { next(err); return; diff --git a/lib/do_instance/do_list.js b/lib/do_instance/do_list.js index adbdbf0..418162b 100644 --- a/lib/do_instance/do_list.js +++ b/lib/do_instance/do_list.js @@ -169,7 +169,7 @@ do_list.help = [ '{{options}}', 'Filters:', ' FIELD=VALUE Equality filter. Supported fields: type, brand, name,', - ' image, state, memory, and tag', + ' image, state, and memory', ' FIELD=true|false Boolean filter. Supported fields: docker (added in', ' CloudAPI 8.0.0)', '', diff --git a/lib/do_instance/do_tag/do_delete.js b/lib/do_instance/do_tag/do_delete.js new file mode 100644 index 0000000..5a63de4 --- /dev/null +++ b/lib/do_instance/do_tag/do_delete.js @@ -0,0 +1,112 @@ +/* + * 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 instance tag delete ...` + */ + +var vasync = require('vasync'); + +var errors = require('../../errors'); + + +function do_delete(subcmd, opts, args, cb) { + var self = this; + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length < 1) { + cb(new errors.UsageError('incorrect number of args')); + return; + } else if (args.length > 1 && opts.all) { + cb(new errors.UsageError('cannot specify both tag names and --all')); + return; + } + var waitTimeoutMs = opts.wait_timeout * 1000; /* seconds to ms */ + + if (opts.all) { + self.top.tritonapi.deleteAllInstanceTags({ + id: args[0], + wait: opts.wait, + waitTimeout: waitTimeoutMs + }, function (err) { + console.log('Deleted all tags on instance %s', args[0]); + cb(err); + }); + } else { + // Uniq'ify the given names. + var names = {}; + args.slice(1).forEach(function (arg) { names[arg] = true; }); + names = Object.keys(names); + + // TODO: Instead of waiting for each delete, let's delete them all then + // wait for the set. + vasync.forEachPipeline({ + inputs: names, + func: function deleteOne(name, next) { + self.top.tritonapi.deleteInstanceTag({ + id: args[0], + tag: name, + wait: opts.wait, + waitTimeout: waitTimeoutMs + }, function (err) { + if (!err) { + console.log('Deleted tag %s on instance %s', + name, args[0]); + } + next(err); + }); + } + }, cb); + } +} + +do_delete.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['all', 'a'], + type: 'bool', + help: 'Remove all tags on this instance.' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for the tag changes to be applied.' + }, + { + 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.' + } +]; + +do_delete.help = [ + /* BEGIN JSSTYLED */ + 'Delete one or more instance tags.', + '', + 'Usage:', + ' {{name}} delete [ ...]', + ' {{name}} delete --all # delete all tags', + '', + '{{options}}', + 'Where is an instance id, name, or shortid and is a tag name.', + '', + 'Changing instance tags is asynchronous. Use "--wait" to not return until', + 'the changes are completed.' + /* END JSSTYLED */ +].join('\n'); + +do_delete.aliases = ['rm']; + +module.exports = do_delete; diff --git a/lib/do_instance/do_tag/do_get.js b/lib/do_instance/do_tag/do_get.js new file mode 100644 index 0000000..b6089dc --- /dev/null +++ b/lib/do_instance/do_tag/do_get.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 2016 Joyent, Inc. + * + * `triton instance tag get ...` + */ + +var errors = require('../../errors'); + + +function do_get(subcmd, opts, args, cb) { + var self = this; + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 2) { + cb(new errors.UsageError('incorrect number of args')); + return; + } + + self.top.tritonapi.getInstanceTag({ + id: args[0], + tag: args[1] + }, function (err, value) { + if (err) { + cb(err); + return; + } + if (opts.json) { + console.log(JSON.stringify(value)); + } else { + console.log(value); + } + cb(); + }); +} + +do_get.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON output.' + } +]; + +do_get.help = [ + /* BEGIN JSSTYLED */ + 'Get an instance tag.', + '', + 'Usage:', + ' {{name}} get ', + '', + '{{options}}', + 'Where is an instance id, name, or shortid and is a tag name.' + /* END JSSTYLED */ +].join('\n'); + +module.exports = do_get; diff --git a/lib/do_instance/do_tag/do_list.js b/lib/do_instance/do_tag/do_list.js new file mode 100644 index 0000000..b6d9e3e --- /dev/null +++ b/lib/do_instance/do_tag/do_list.js @@ -0,0 +1,69 @@ +/* + * 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 instance tag list ...` + */ + +var errors = require('../../errors'); + +function do_list(subcmd, opts, args, cb) { + var self = this; + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 1) { + cb(new errors.UsageError('incorrect number of args')); + return; + } + + self.top.tritonapi.listInstanceTags({id: args[0]}, function (err, tags) { + if (err) { + cb(err); + return; + } + if (opts.json) { + console.log(JSON.stringify(tags)); + } else { + console.log(JSON.stringify(tags, null, 4)); + } + cb(); + }); +} + +do_list.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON output.' + } +]; + +do_list.help = [ + /* BEGIN JSSTYLED */ + 'List instance tags.', + '', + 'Usage:', + ' {{name}} list ', + '', + '{{options}}', + 'Where is an instance id, name, or shortid.', + '', + '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_list.aliases = ['ls']; + +module.exports = do_list; diff --git a/lib/do_instance/do_tag/do_replace_all.js b/lib/do_instance/do_tag/do_replace_all.js new file mode 100644 index 0000000..3ebd877 --- /dev/null +++ b/lib/do_instance/do_tag/do_replace_all.js @@ -0,0 +1,132 @@ +/* + * 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 instance tag replace-all ...` + */ + +var vasync = require('vasync'); + +var errors = require('../../errors'); +var mat = require('../../metadataandtags'); + + +function do_replace_all(subcmd, opts, args, cb) { + var self = this; + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length < 1) { + cb(new errors.UsageError('incorrect number of args')); + return; + } + var log = self.log; + + vasync.pipeline({arg: {}, funcs: [ + function gatherTags(ctx, next) { + mat.tagsFromSetArgs(opts, args.slice(1), log, function (err, tags) { + if (err) { + next(err); + return; + } + log.trace({tags: tags || ''}, + 'tags loaded from opts and args'); + ctx.tags = tags; + next(); + }); + }, + + function replaceAway(ctx, next) { + if (!ctx.tags) { + next(new errors.UsageError('no tags were provided')); + return; + } + self.top.tritonapi.replaceAllInstanceTags({ + id: args[0], + tags: ctx.tags, + wait: opts.wait, + waitTimeout: opts.wait_timeout * 1000 /* seconds to ms */ + }, function (err, updatedTags) { + if (err) { + cb(err); + return; + } + if (!opts.quiet) { + if (opts.json) { + console.log(JSON.stringify(updatedTags)); + } else { + console.log(JSON.stringify(updatedTags, null, 4)); + } + } + cb(); + }); + } + ]}, cb); +} + +do_replace_all.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['file', 'f'], + type: 'arrayOfString', + helpArg: 'FILE', + help: 'Load tag name/value pairs from the given file path. ' + + 'The file may contain a JSON object or a file with "NAME=VALUE" ' + + 'pairs, one per line. This option can be used multiple times.' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for the tag changes to be applied.' + }, + { + 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: ['json', 'j'], + type: 'bool', + help: 'JSON output.' + }, + { + names: ['quiet', 'q'], + type: 'bool', + help: 'Quieter output. Specifically do not dump the updated set of ' + + 'tags on successful completion.' + } +]; + +do_replace_all.help = [ + /* BEGIN JSSTYLED */ + 'Replace all tags on the given instance.', + '', + 'Usage:', + ' {{name}} replace-all [= ...]', + ' {{name}} replace-all -f # tags from file', + '', + '{{options}}', + 'Where is an instance id, name, or shortid; is a tag name;', + 'and is a tag value (bool and numeric "value" are converted to ', + 'that type).', + '', + 'Currently this dumps prettified JSON by default. That might change in the', + 'future. Use "-j" to explicitly get JSON output.', + '', + 'Changing instance tags is asynchronous. Use "--wait" to not return until', + 'the changes are completed.' + /* END JSSTYLED */ +].join('\n'); + +module.exports = do_replace_all; diff --git a/lib/do_instance/do_tag/do_set.js b/lib/do_instance/do_tag/do_set.js new file mode 100644 index 0000000..d776331 --- /dev/null +++ b/lib/do_instance/do_tag/do_set.js @@ -0,0 +1,133 @@ +/* + * 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 instance tag set ...` + */ + +var vasync = require('vasync'); + +var errors = require('../../errors'); +var mat = require('../../metadataandtags'); + + +function do_set(subcmd, opts, args, cb) { + var self = this; + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length < 1) { + cb(new errors.UsageError('incorrect number of args')); + return; + } + var log = self.log; + + vasync.pipeline({arg: {}, funcs: [ + function gatherTags(ctx, next) { + mat.tagsFromSetArgs(opts, args.slice(1), log, function (err, tags) { + if (err) { + next(err); + return; + } + log.trace({tags: tags || ''}, + 'tags loaded from opts and args'); + ctx.tags = tags; + next(); + }); + }, + + function setMachineTags(ctx, next) { + if (!ctx.tags) { + log.trace('no tags to set'); + next(); + return; + } + self.top.tritonapi.setInstanceTags({ + id: args[0], + tags: ctx.tags, + wait: opts.wait, + waitTimeout: opts.wait_timeout * 1000 /* seconds to ms */ + }, function (err, updatedTags) { + if (err) { + cb(err); + return; + } + if (!opts.quiet) { + if (opts.json) { + console.log(JSON.stringify(updatedTags)); + } else { + console.log(JSON.stringify(updatedTags, null, 4)); + } + } + cb(); + }); + } + ]}, cb); +} + +do_set.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['file', 'f'], + type: 'arrayOfString', + helpArg: 'FILE', + help: 'Load tag name/value pairs from the given file path. ' + + 'The file may contain a JSON object or a file with "NAME=VALUE" ' + + 'pairs, one per line. This option can be used multiple times.' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for the tag changes to be applied.' + }, + { + 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: ['json', 'j'], + type: 'bool', + help: 'JSON output.' + }, + { + names: ['quiet', 'q'], + type: 'bool', + help: 'Quieter output. Specifically do not dump the updated set of ' + + 'tags on successful completion.' + } +]; + +do_set.help = [ + /* BEGIN JSSTYLED */ + 'Set one or more instance tags.', + '', + 'Usage:', + ' {{name}} set [= ...]', + ' {{name}} set -f # tags from file', + '', + '{{options}}', + 'Where is an instance id, name, or shortid; is a tag name;', + 'and is a tag value (bool and numeric "value" are converted to ', + 'that type).', + '', + 'Currently this dumps prettified JSON by default. That might change in the', + 'future. Use "-j" to explicitly get JSON output.', + '', + 'Changing instance tags is asynchronous. Use "--wait" to not return until', + 'the changes are completed.' + /* END JSSTYLED */ +].join('\n'); + +module.exports = do_set; diff --git a/lib/do_instance/do_tag/index.js b/lib/do_instance/do_tag/index.js new file mode 100644 index 0000000..ca1f8b9 --- /dev/null +++ b/lib/do_instance/do_tag/index.js @@ -0,0 +1,54 @@ +/* + * 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 instance tag ...` + */ + +var Cmdln = require('cmdln').Cmdln; +var util = require('util'); + + +// ---- CLI class + +function InstanceTagCLI(parent) { + this.top = parent.top; + Cmdln.call(this, { + name: parent.name + ' tag', + /* BEGIN JSSTYLED */ + desc: [ + 'List, get, set and delete tags on Triton instances.' + ].join('\n'), + /* END JSSTYLED */ + helpOpts: { + minHelpCol: 24 /* line up with option help */ + }, + helpSubcmds: [ + 'help', + 'list', + 'get', + 'set', + 'replace-all', + 'delete' + ] + }); +} +util.inherits(InstanceTagCLI, Cmdln); + +InstanceTagCLI.prototype.init = function init(opts, args, cb) { + this.log = this.top.log; + Cmdln.prototype.init.apply(this, arguments); +}; + +InstanceTagCLI.prototype.do_list = require('./do_list'); +InstanceTagCLI.prototype.do_get = require('./do_get'); +InstanceTagCLI.prototype.do_set = require('./do_set'); +InstanceTagCLI.prototype.do_replace_all = require('./do_replace_all'); +InstanceTagCLI.prototype.do_delete = require('./do_delete'); + +module.exports = InstanceTagCLI; diff --git a/lib/do_instance/do_tags.js b/lib/do_instance/do_tags.js new file mode 100644 index 0000000..2896a94 --- /dev/null +++ b/lib/do_instance/do_tags.js @@ -0,0 +1,25 @@ +/* + * 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 instance tags ...` shortcut for `triton instance tag list ...`. + */ + +function do_tags(subcmd, opts, args, callback) { + this.handlerFromSubcmd('tag').dispatch({ + subcmd: 'list', + opts: opts, + args: args + }, callback); +} + +do_tags.help = 'A shortcut for "triton instance tag list".'; +do_tags.options = require('./do_tag/do_list').options; +do_tags.hidden = true; + +module.exports = do_tags; diff --git a/lib/do_instance/index.js b/lib/do_instance/index.js index 491e5a5..72b4c92 100644 --- a/lib/do_instance/index.js +++ b/lib/do_instance/index.js @@ -42,7 +42,8 @@ function InstanceCLI(top) { 'ssh', 'wait', 'audit', - 'fwrules' + 'fwrules', + 'tag' ] }); } @@ -66,6 +67,8 @@ InstanceCLI.prototype.do_ssh = require('./do_ssh'); InstanceCLI.prototype.do_wait = require('./do_wait'); InstanceCLI.prototype.do_audit = require('./do_audit'); InstanceCLI.prototype.do_fwrules = require('./do_fwrules'); +InstanceCLI.prototype.do_tag = require('./do_tag'); +InstanceCLI.prototype.do_tags = require('./do_tags'); InstanceCLI.aliases = ['inst']; diff --git a/lib/do_profile/do_list.js b/lib/do_profile/do_list.js index 1bc5813..c38e2de 100644 --- a/lib/do_profile/do_list.js +++ b/lib/do_profile/do_list.js @@ -1,7 +1,7 @@ /* - * Copyright (c) 2015 Joyent Inc. + * Copyright 2016 Joyent Inc. * - * `triton profiles ...` + * `triton profile list ...` */ var tabula = require('tabula'); @@ -38,9 +38,20 @@ function _listProfiles(cli, opts, args, cb) { } // Current profile: Set 'curr' field. Apply CLI overrides. + var currProfile; + try { + currProfile = cli.tritonapi.profile; + } catch (err) { + // Ignore inability to load a profile. + if (!(err instanceof errors.ConfigError)) { + throw err; + } + } + var haveCurr = false; for (i = 0; i < profiles.length; i++) { var profile = profiles[i]; - if (profile.name === cli.tritonapi.profile.name) { + if (currProfile && profile.name === currProfile.name) { + haveCurr = true; cli._applyProfileOverrides(profile); if (opts.json) { profile.curr = true; @@ -66,6 +77,10 @@ function _listProfiles(cli, opts, args, cb) { columns: columns, sort: sort }); + if (!haveCurr) { + process.stderr.write('\nWarning: There is no current profile. ' + + 'Use "triton profile set-current ..."\nto set one.\n'); + } } cb(); } diff --git a/lib/do_profile/profilecommon.js b/lib/do_profile/profilecommon.js index d650b6d..5edfba0 100644 --- a/lib/do_profile/profilecommon.js +++ b/lib/do_profile/profilecommon.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 Joyent Inc. + * Copyright 2016 Joyent Inc. * * Shared stuff for `triton profile ...` handling. */ @@ -7,7 +7,7 @@ var assert = require('assert-plus'); var mod_config = require('../config'); - +var errors = require('../errors'); function setCurrentProfile(opts, cb) { @@ -25,7 +25,16 @@ function setCurrentProfile(opts, cb) { return cb(err); } - if (cli.tritonapi.profile.name === profile.name) { + var currProfile; + try { + currProfile = cli.tritonapi.profile; + } catch (err) { + // Ignore inability to load a profile. + if (!(err instanceof errors.ConfigError)) { + throw err; + } + } + if (currProfile && currProfile.name === profile.name) { console.log('"%s" is already the current profile', profile.name); return cb(); } diff --git a/lib/errors.js b/lib/errors.js index aee636b..abe333f 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -182,6 +182,7 @@ util.inherits(SigningError, _TritonBaseVError); * A 'DEPTH_ZERO_SELF_SIGNED_CERT' An error signing a request. */ function SelfSignedCertError(cause, url) { + assert.string(url, 'url'); var msg = format('could not access CloudAPI %s because it uses a ' + 'self-signed TLS certificate and your current profile is not ' + 'configured for insecure access', url); @@ -195,6 +196,25 @@ function SelfSignedCertError(cause, url) { util.inherits(SelfSignedCertError, _TritonBaseVError); +/** + * A timeout was reached waiting/polling for something. + */ +function TimeoutError(cause, msg) { + if (msg === undefined) { + msg = cause; + cause = undefined; + } + assert.string(msg, 'msg'); + _TritonBaseVError.call(this, { + cause: cause, + message: msg, + code: 'Timeout', + exitStatus: 1 + }); +} +util.inherits(TimeoutError, _TritonBaseVError); + + /** * A resource (instance, image, ...) was not found. */ @@ -244,6 +264,7 @@ module.exports = { UsageError: UsageError, SigningError: SigningError, SelfSignedCertError: SelfSignedCertError, + TimeoutError: TimeoutError, ResourceNotFoundError: ResourceNotFoundError, MultiError: MultiError }; diff --git a/lib/index.js b/lib/index.js index 87b436e..c050222 100644 --- a/lib/index.js +++ b/lib/index.js @@ -81,7 +81,7 @@ var tritonapi = require('./tritonapi'); * - @param profileName {String} A Triton profile name. For any profile * name other than "env", one must also provide either `configDir` * or `config`. - * Either `profile` or `profileName` is requires. See discussion above. + * Either `profile` or `profileName` is required. See discussion above. * - @param configDir {String} A base config directory. This is used * by TritonApi to find and store profiles, config, and cache data. * For example, the `triton` CLI uses "~/.triton". diff --git a/lib/metadataandtags.js b/lib/metadataandtags.js index 480f9a0..e9438a6 100644 --- a/lib/metadataandtags.js +++ b/lib/metadataandtags.js @@ -84,7 +84,7 @@ function metadataFromOpts(opts, log, cb) { * * says values may be string, num or bool. */ -function tagsFromOpts(opts, log, cb) { +function tagsFromCreateOpts(opts, log, cb) { assert.arrayOfObject(opts._order, 'opts._order'); assert.object(log, 'log'); assert.func(cb, 'cb'); @@ -123,6 +123,60 @@ function tagsFromOpts(opts, log, cb) { } +/* + * Load and validate tags from (a) these options: + * -f,--file FILE + * and (b) these args: + * name=value ... + * + * Later ones win, so *args* will win over file-loaded tags. + * + * + * says values may be string, num or bool. + */ +function tagsFromSetArgs(opts, args, log, cb) { + assert.arrayOfObject(opts._order, 'opts._order'); + assert.arrayOfString(args, 'args'); + assert.object(log, 'log'); + assert.func(cb, 'cb'); + + var tags = {}; + + vasync.pipeline({funcs: [ + function tagsFromOpts(_, next) { + vasync.forEachPipeline({ + inputs: opts._order, + func: function tagsFromOpt(o, nextOpt) { + log.trace({opt: o}, 'tagsFromOpt'); + if (o.key === 'file') { + _addMetadataFromFile('tag', tags, o.value, nextOpt); + } else { + nextOpt(); + } + } + }, next); + }, + function tagsFromArgs(_, next) { + vasync.forEachPipeline({ + inputs: args, + func: function tagFromArg(a, nextArg) { + log.trace({arg: a}, 'tagFromArg'); + _addMetadataFromKvStr('tag', tags, a, null, nextArg); + } + }, next); + } + ]}, function (err) { + if (err) { + cb(err); + } else if (Object.keys(tags).length) { + cb(null, tags); + } else { + cb(); + } + }); +} + + var allowedTypes = ['string', 'number', 'boolean']; function _addMetadatum(ilk, metadata, key, value, from, cb) { assert.string(ilk, 'ilk'); @@ -221,6 +275,10 @@ function _addMetadataFromFile(ilk, metadata, file, cb) { function _addMetadataFromKvStr(ilk, metadata, s, from, cb) { assert.string(ilk, 'ilk'); + assert.object(metadata, 'metadata'); + assert.string(s, 's'); + assert.optionalString(from, 'from'); + assert.func(cb, 'cb'); var parts = strsplit(s, '=', 2); if (parts.length !== 2) { @@ -285,5 +343,6 @@ function _addMetadatumFromFile(ilk, metadata, key, file, from, cb) { module.exports = { metadataFromOpts: metadataFromOpts, - tagsFromOpts: tagsFromOpts + tagsFromCreateOpts: tagsFromCreateOpts, + tagsFromSetArgs: tagsFromSetArgs }; diff --git a/lib/tritonapi.js b/lib/tritonapi.js index c456d74..5efc2a2 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -59,6 +59,32 @@ function _roleTagResourceUrl(account, type, id) { return format('/%s/%s/%s', account, ns, id); } +/** + * A function appropriate for `vasync.pipeline` funcs that takes a `arg.id` + * instance name, shortid or uuid, and determines the instance id (setting it + * as `arg.instId`). + */ +function _stepInstId(arg, next) { + assert.object(arg.client, 'arg.client'); + assert.string(arg.id, 'arg.id'); + + if (common.isUUID(arg.id)) { + arg.instId = arg.id; + next(); + } else { + arg.client.getInstance({ + id: arg.id, + fields: ['id'] + }, function (err, inst) { + if (err) { + next(err); + } else { + arg.instId = inst.id; + next(); + } + }); + } +} //---- TritonApi class @@ -585,16 +611,28 @@ TritonApi.prototype.getFirewallRule = function getFirewallRule(id, cb) { /** - * Get an instance by ID, exact name, or short ID, in that order. + * Get an instance. * - * @param {String} name + * Alternative call signature: `getInstance(id, callback)`. + * + * @param {Object} opts + * - {UUID} id: The instance ID, name, or short ID. Required. + * - {Array} fields: Optional. An array of instance field names that are + * wanted by the caller. This *can* allow the implementation to avoid + * extra API calls. E.g. `['id', 'name']`. * @param {Function} callback `function (err, inst, res)` - * Where, on success, `res` is the response object from a `GetMachine` call - * if one was made. + * On success, `res` is the response object from a `GetMachine`, if one + * was made (possibly not if the instance was retrieved from `ListMachines` + * calls). */ -TritonApi.prototype.getInstance = function getInstance(name, cb) { +TritonApi.prototype.getInstance = function getInstance(opts, cb) { var self = this; - assert.string(name, 'name'); + if (typeof (opts) === 'string') { + opts = {id: opts}; + } + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.optionalArrayOfString(opts.fields, 'opts.fields'); assert.func(cb, 'cb'); var res; @@ -605,10 +643,10 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { vasync.pipeline({funcs: [ function tryUuid(_, next) { var uuid; - if (common.isUUID(name)) { - uuid = name; + if (common.isUUID(opts.id)) { + uuid = opts.id; } else { - shortId = common.normShortId(name); + shortId = common.normShortId(opts.id); if (shortId && common.isUUID(shortId)) { // E.g. a >32-char docker container ID normalized to a UUID. uuid = shortId; @@ -622,7 +660,7 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { if (err && err.restCode === 'ResourceNotFound') { // The CloudApi 404 error message sucks: "VM not found". err = new errors.ResourceNotFoundError(err, - format('instance with id %s was not found', name)); + format('instance with id %s was not found', opts.id)); } next(err); }); @@ -633,12 +671,12 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { return next(); } - self.cloudapi.listMachines({name: name}, function (err, insts) { + self.cloudapi.listMachines({name: opts.id}, function (err, insts) { if (err) { return next(err); } for (var i = 0; i < insts.length; i++) { - if (insts[i].name === name) { + if (insts[i].name === opts.id) { instFromList = insts[i]; // Relying on rule that instance name is unique // for a user and DC. @@ -692,7 +730,22 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { if (inst || !instFromList) { next(); return; + } else if (opts.fields) { + // If already have all the requested fields, no need to re-get. + var missingAField = false; + for (var i = 0; i < opts.fields.length; i++) { + if (! instFromList.hasOwnProperty(opts.fields[i])) { + missingAField = true; + break; + } + } + if (!missingAField) { + inst = instFromList; + next(); + return; + } } + var uuid = instFromList.id; self.cloudapi.getMachine(uuid, function (err, inst_, res_) { res = res_; @@ -700,7 +753,7 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { if (err && err.restCode === 'ResourceNotFound') { // The CloudApi 404 error message sucks: "VM not found". err = new errors.ResourceNotFoundError(err, - format('instance with id %s was not found', name)); + format('instance with id %s was not found', opts.id)); } next(err); }); @@ -712,12 +765,528 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { cb(null, inst, res); } else { cb(new errors.ResourceNotFoundError(format( - 'no instance with name or short id "%s" was found', name))); + 'no instance with name or short id "%s" was found', opts.id))); } }); }; +// ---- instance tags + +/** + * List an instance's tags. + * + * + * Alternative call signature: `listInstanceTags(id, callback)`. + * + * @param {Object} opts + * - {UUID} id: The instance ID, name, or short ID. Required. + * @param {Function} cb: `function (err, tags, res)` + * On success, `res` is *possibly* the response object from either a + * `ListMachineTags` or a `GetMachine` call. + */ +TritonApi.prototype.listInstanceTags = function listInstanceTags(opts, cb) { + var self = this; + if (typeof (opts) === 'string') { + opts = {id: opts}; + } + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + if (common.isUUID(opts.id)) { + self.cloudapi.listMachineTags(opts, cb); + return; + } + + self.getInstance({ + id: opts.id, + fields: ['id', 'tags'] + }, function (err, inst, res) { + if (err) { + cb(err); + return; + } + // No need to call `ListMachineTags` now. + cb(null, inst.tags, res); + }); +}; + + +/** + * Get an instance tag value. + * + * + * @param {Object} opts + * - {UUID} id: The instance ID, name, or short ID. Required. + * - {String} tag: The tag name. Required. + * @param {Function} cb: `function (err, value, res)` + * On success, `value` is the tag value *as a string*. See note above. + * On success, `res` is *possibly* the response object from either a + * `GetMachineTag` or a `GetMachine` call. + */ +TritonApi.prototype.getInstanceTag = function getInstanceTag(opts, cb) { + var self = this; + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.string(opts.tag, 'opts.tag'); + assert.func(cb, 'cb'); + + if (common.isUUID(opts.id)) { + self.cloudapi.getMachineTag(opts, cb); + return; + } + + self.getInstance({ + id: opts.id, + fields: ['id', 'tags'] + }, function (err, inst, res) { + if (err) { + cb(err); + return; + } + // No need to call `GetMachineTag` now. + if (inst.tags.hasOwnProperty(opts.tag)) { + var value = inst.tags[opts.tag]; + cb(null, value, res); + } else { + cb(new errors.ResourceNotFoundError(format( + 'tag with name "%s" was not found', opts.tag))); + } + }); +}; + + +/** + * Shared implementation for any methods to change instance tags. + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {Object} change: Required. Describes the tag change to make. It + * has an "action" field and, depending on the particular action, a + * "tags" field. + * - {Boolean} wait: Wait (via polling) until the tag update is complete. + * Warning: A concurrent tag update to the same tags can result in this + * polling being unable to notice the change. Use `waitTimeout` to + * put an upper bound. + * - {Number} waitTimeout: The number of milliseconds after which to + * timeout (call `cb` with a timeout error) waiting. Only relevant if + * `opts.wait === true`. Default is Infinity (i.e. it doesn't timeout). + * @param {Function} cb: `function (err, tags, res)` + * On success, `tags` is the updated set of instance tags and `res` is + * the response object from the underlying CloudAPI call. Note that `tags` + * is not set (undefined) for the "delete" and "deleteAll" actions. + */ +TritonApi.prototype._changeInstanceTags = +function _changeInstanceTags(opts, cb) { + var self = this; + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.object(opts.change, 'opts.change'); + var KNOWN_CHANGE_ACTIONS = ['set', 'replace', 'delete', 'deleteAll']; + assert.ok(KNOWN_CHANGE_ACTIONS.indexOf(opts.change.action) != -1, + 'invalid change action: ' + opts.change.action); + switch (opts.change.action) { + case 'set': + case 'replace': + assert.object(opts.change.tags, + 'opts.change.tags for action=' + opts.change.action); + break; + case 'delete': + assert.string(opts.change.tagName, + 'opts.change.tagName for action=delete'); + break; + case 'deleteAll': + break; + default: + throw new Error('unexpected action: ' + opts.change.action); + } + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalNumber(opts.waitTimeout, 'opts.waitTimeout'); + assert.func(cb, 'cb'); + + var theRes; + var updatedTags; + vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [ + _stepInstId, + + function changeTheTags(arg, next) { + switch (opts.change.action) { + case 'set': + self.cloudapi.addMachineTags({ + id: arg.instId, + tags: opts.change.tags + }, function (err, tags, res) { + updatedTags = tags; + theRes = res; + next(err); + }); + break; + case 'replace': + self.cloudapi.replaceMachineTags({ + id: arg.instId, + tags: opts.change.tags + }, function (err, tags, res) { + updatedTags = tags; + theRes = res; + next(err); + }); + break; + case 'delete': + self.cloudapi.deleteMachineTag({ + id: arg.instId, + tag: opts.change.tagName + }, function (err, res) { + theRes = res; + next(err); + }); + break; + case 'deleteAll': + self.cloudapi.deleteMachineTags({ + id: arg.instId + }, function (err, res) { + theRes = res; + next(err); + }); + break; + default: + throw new Error('unexpected action: ' + opts.change.action); + } + }, + + function waitForChanges(arg, next) { + if (!opts.wait) { + next(); + return; + } + self.waitForInstanceTagChanges({ + id: arg.instId, + timeout: opts.waitTimeout, + change: opts.change + }, next); + } + ]}, function (err) { + if (err) { + cb(err); + } else { + cb(null, updatedTags, theRes); + } + }); +}; + + +/** + * Wait (via polling) for the given tag changes to have taken on the instance. + * + * Dev Note: This polls `ListMachineTags` until it looks like the given changes + * have been applied. This is unreliable with concurrent tag updates. A + * workaround for that is `opts.timeout`. A better long term solution would + * be for cloudapi to expose some handle on the underlying Triton workflow + * jobs performing these, and poll/wait on those. + * + * @param {Object} opts: Required. + * - {UUID} id: Required. The instance id. + * Limitation: Currently requiring this to be the full instance UUID. + * - {Number} timeout: Optional. A number of milliseconds after which to + * timeout (callback with `TimeoutError`) the wait. By default this is + * Infinity. + * - {Object} changes: Required. It always has an 'action' field (one of + * 'set', 'replace', 'delete', 'deleteAll') and, depending on the + * action, a 'tags' (set, replace), 'tagName' (delete) or 'tagNames' + * (delete). + * @param {Function} cb: `function (err, updatedTags)` + * On failure, `err` can be an error from `ListMachineTags` or + * `TimeoutError`. + */ +TritonApi.prototype.waitForInstanceTagChanges = +function waitForInstanceTagChanges(opts, cb) { + var self = this; + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.optionalNumber(opts.timeout, 'opts.timeout'); + var timeout = opts.hasOwnProperty('timeout') ? opts.timeout : Infinity; + assert.ok(timeout > 0, 'opts.timeout must be greater than zero'); + assert.object(opts.change, 'opts.change'); + var KNOWN_CHANGE_ACTIONS = ['set', 'replace', 'delete', 'deleteAll']; + assert.ok(KNOWN_CHANGE_ACTIONS.indexOf(opts.change.action) != -1, + 'invalid change action: ' + opts.change.action); + assert.func(cb, 'cb'); + + var tagNames; + switch (opts.change.action) { + case 'set': + case 'replace': + assert.object(opts.change.tags, 'opts.change.tags'); + break; + case 'delete': + if (opts.change.tagNames) { + assert.arrayOfString(opts.change.tagNames, 'opts.change.tagNames'); + tagNames = opts.change.tagNames; + } else { + assert.string(opts.change.tagName, 'opts.change.tagName'); + tagNames = [opts.change.tagName]; + } + break; + case 'deleteAll': + break; + default: + throw new Error('unexpected action: ' + opts.change.action); + } + + /* + * Hardcoded 2s poll interval for now. Not yet configurable, being mindful + * of avoiding lots of clients naively swamping a CloudAPI and hitting + * throttling. + * TODO: General client support for dealing with polling and throttling. + */ + var POLL_INTERVAL = 2 * 1000; + + var startTime = Date.now(); + + var poll = function () { + self.log.trace({id: opts.id}, 'waitForInstanceTagChanges: poll inst'); + self.cloudapi.listMachineTags({id: opts.id}, function (err, tags) { + if (err) { + cb(err); + return; + } + + // Determine in changes are not yet applied (incomplete). + var incomplete = false; + var i, k, keys; + switch (opts.change.action) { + case 'set': + keys = Object.keys(opts.change.tags); + for (i = 0; i < keys.length; i++) { + k = keys[i]; + if (tags[k] !== opts.change.tags[k]) { + self.log.trace({tag: k}, + 'waitForInstanceTagChanges incomplete set: ' + + 'unexpected value for tag'); + incomplete = true; + break; + } + } + break; + case 'replace': + keys = Object.keys(opts.change.tags); + var tagsCopy = common.objCopy(tags); + for (i = 0; i < keys.length; i++) { + k = keys[i]; + if (tagsCopy[k] !== opts.change.tags[k]) { + self.log.trace({tag: k}, + 'waitForInstanceTagChanges incomplete replace: ' + + 'unexpected value for tag'); + incomplete = true; + break; + } + delete tagsCopy[k]; + } + var extraneousTags = Object.keys(tagsCopy); + if (extraneousTags.length > 0) { + self.log.trace({extraneousTags: extraneousTags}, + 'waitForInstanceTagChanges incomplete replace: ' + + 'extraneous tags'); + incomplete = true; + } + break; + case 'delete': + for (i = 0; i < tagNames.length; i++) { + k = tagNames[i]; + if (tags.hasOwnProperty(k)) { + self.log.trace({tag: k}, + 'waitForInstanceTagChanges incomplete delete: ' + + 'extraneous tag'); + incomplete = true; + break; + } + } + break; + case 'deleteAll': + if (Object.keys(tags).length > 0) { + self.log.trace({tag: k}, + 'waitForInstanceTagChanges incomplete deleteAll: ' + + 'still have tags'); + incomplete = true; + } + break; + default: + throw new Error('unexpected action: ' + opts.change.action); + } + + if (!incomplete) { + self.log.trace('waitForInstanceTagChanges: complete'); + cb(null, tags); + } else { + var elapsedTime = Date.now() - startTime; + if (elapsedTime > timeout) { + cb(new errors.TimeoutError(format('timeout waiting for ' + + 'tag changes on instance %s (elapsed %ds)', + opts.id, Math.round(elapsedTime * 1000)))); + } else { + setTimeout(poll, POLL_INTERVAL); + } + } + }); + }; + + setImmediate(poll); +}; + + +/** + * Set instance tags. + * + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {Object} tags: The tag name/value pairs. Required. + * - {Boolean} wait: Wait (via polling) until the tag update is complete. + * Warning: A concurrent tag update to the same tags can result in this + * polling being unable to notice the change. Use `waitTimeout` to + * put an upper bound. + * - {Number} waitTimeout: The number of milliseconds after which to + * timeout (call `cb` with a timeout error) waiting. Only relevant if + * `opts.wait === true`. Default is Infinity (i.e. it doesn't timeout). + * @param {Function} cb: `function (err, updatedTags, res)` + * On success, `updatedTags` is the updated set of instance tags and `res` + * is the response object from the `AddMachineTags` CloudAPI call. + */ +TritonApi.prototype.setInstanceTags = function setInstanceTags(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.object(opts.tags, 'opts.tags'); + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalNumber(opts.waitTimeout, 'opts.waitTimeout'); + assert.func(cb, 'cb'); + + this._changeInstanceTags({ + id: opts.id, + change: { + action: 'set', + tags: opts.tags + }, + wait: opts.wait, + waitTimeout: opts.waitTimeout + }, cb); +}; + + +/** + * Replace all instance tags. + * + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {Object} tags: The tag name/value pairs. Required. + * - {Boolean} wait: Wait (via polling) until the tag update is complete. + * Warning: A concurrent tag update to the same tags can result in this + * polling being unable to notice the change. Use `waitTimeout` to + * put an upper bound. + * - {Number} waitTimeout: The number of milliseconds after which to + * timeout (call `cb` with a timeout error) waiting. Only relevant if + * `opts.wait === true`. Default is Infinity (i.e. it doesn't timeout). + * @param {Function} cb: `function (err, tags, res)` + * On success, `tags` is the updated set of instance tags and `res` is + * the response object from the `ReplaceMachineTags` CloudAPI call. + */ +TritonApi.prototype.replaceAllInstanceTags = +function replaceAllInstanceTags(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.object(opts.tags, 'opts.tags'); + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalNumber(opts.waitTimeout, 'opts.waitTimeout'); + assert.func(cb, 'cb'); + + this._changeInstanceTags({ + id: opts.id, + change: { + action: 'replace', + tags: opts.tags + }, + wait: opts.wait, + waitTimeout: opts.waitTimeout + }, cb); +}; + + +/** + * Delete the named instance tag. + * + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {String} tag: The tag name. Required. + * - {Boolean} wait: Wait (via polling) until the tag update is complete. + * Warning: A concurrent tag update to the same tags can result in this + * polling being unable to notice the change. Use `waitTimeout` to + * put an upper bound. + * - {Number} waitTimeout: The number of milliseconds after which to + * timeout (call `cb` with a timeout error) waiting. Only relevant if + * `opts.wait === true`. Default is Infinity (i.e. it doesn't timeout). + * @param {Function} cb: `function (err, res)` + */ +TritonApi.prototype.deleteInstanceTag = function deleteInstanceTag(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.string(opts.tag, 'opts.tag'); + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalNumber(opts.waitTimeout, 'opts.waitTimeout'); + assert.func(cb, 'cb'); + + this._changeInstanceTags({ + id: opts.id, + change: { + action: 'delete', + tagName: opts.tag + }, + wait: opts.wait, + waitTimeout: opts.waitTimeout + }, function (err, updatedTags, res) { + cb(err, res); + }); +}; + + +/** + * Delete all tags for the given instance. + * + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {Boolean} wait: Wait (via polling) until the tag update is complete. + * Warning: A concurrent tag update to the same tags can result in this + * polling being unable to notice the change. Use `waitTimeout` to + * put an upper bound. + * - {Number} waitTimeout: The number of milliseconds after which to + * timeout (call `cb` with a timeout error) waiting. Only relevant if + * `opts.wait === true`. Default is Infinity (i.e. it doesn't timeout). + * @param {Function} cb: `function (err, res)` + */ +TritonApi.prototype.deleteAllInstanceTags = +function deleteAllInstanceTags(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalNumber(opts.waitTimeout, 'opts.waitTimeout'); + assert.func(cb, 'cb'); + + this._changeInstanceTags({ + id: opts.id, + change: { + action: 'deleteAll' + }, + wait: opts.wait, + waitTimeout: opts.waitTimeout + }, function (err, updatedTags, res) { + cb(err, res); + }); +}; + + +// ---- RBAC + /** * Get role tags for a resource. * diff --git a/package.json b/package.json index 78da16a..278c8cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "triton", "description": "Joyent Triton CLI and client (https://www.joyent.com/triton)", - "version": "4.4.2", + "version": "4.5.1", "author": "Joyent (joyent.com)", "dependencies": { "assert-plus": "0.2.0", @@ -18,8 +18,8 @@ "restify-clients": "1.1.0", "restify-errors": "3.0.0", "rimraf": "2.4.4", - "sshpk": "1.6.x >=1.6.2", - "smartdc-auth": "2.2.3", + "sshpk": "1.7.x", + "smartdc-auth": "2.3.1", "strsplit": "1.0.0", "tabula": "1.7.0", "vasync": "1.6.3", diff --git a/test/integration/cli-account.test.js b/test/integration/cli-account.test.js index bcb251b..04b42b1 100644 --- a/test/integration/cli-account.test.js +++ b/test/integration/cli-account.test.js @@ -5,7 +5,7 @@ */ /* - * Copyright (c) 2015, Joyent, Inc. + * Copyright 2016, Joyent, Inc. */ /* @@ -47,7 +47,7 @@ test('triton account', function (tt) { }); tt.test(' triton account get', function (t) { - h.triton('account get', function (err, stdout, stderr) { + h.triton('-v account get', function (err, stdout, stderr) { if (h.ifErr(t, err)) return t.end(); t.ok(new RegExp( diff --git a/test/integration/cli-instance-tag.test.js b/test/integration/cli-instance-tag.test.js new file mode 100644 index 0000000..659468d --- /dev/null +++ b/test/integration/cli-instance-tag.test.js @@ -0,0 +1,264 @@ +/* + * 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. + */ + +/* + * Test 'triton inst tag ...'. + */ + +var f = require('util').format; +var os = require('os'); +var path = require('path'); +var tabula = require('tabula'); +var test = require('tape'); +var vasync = require('vasync'); + +var common = require('../../lib/common'); +var h = require('./helpers'); + + +// --- globals + +var INST_ALIAS = f('nodetritontest-insttag-%s', os.hostname()); + +var opts = { + skip: !h.CONFIG.allowWriteActions +}; + + +// --- Tests + +if (opts.skip) { + console.error('** skipping %s tests', __filename); + console.error('** set "allowWriteActions" in test config to enable'); +} +test('triton inst tag ...', opts, function (tt) { + tt.comment('Test config:'); + Object.keys(h.CONFIG).forEach(function (key) { + var value = h.CONFIG[key]; + tt.comment(f('- %s: %j', key, value)); + }); + + var inst; + + tt.test(' cleanup: rm inst ' + INST_ALIAS + ' if exists', function (t) { + h.triton(['inst', 'get', '-j', INST_ALIAS], + function (err, stdout, stderr) { + if (err) { + if (err.code === 3) { // `triton` code for ResourceNotFound + t.ok(true, 'no pre-existing alias in the way'); + t.end(); + } else { + t.ifErr(err); + t.end(); + } + } else { + var oldInst = JSON.parse(stdout); + h.safeTriton(t, ['delete', '-w', oldInst.id], function (dErr) { + t.ifError(dErr, 'deleted old inst ' + oldInst.id); + t.end(); + }); + } + }); + }); + + var imgId; + tt.test(' setup: find test image', function (t) { + h.getTestImg(t, function (err, imgId_) { + t.ifError(err, 'getTestImg' + (err ? ': ' + err : '')); + imgId = imgId_; + t.end(); + }); + }); + + var pkgId; + tt.test(' setup: find test package', function (t) { + h.getTestPkg(t, function (err, pkgId_) { + t.ifError(err, 'getTestPkg' + (err ? ': ' + err : '')); + pkgId = pkgId_; + t.end(); + }); + }); + + // create a test machine (blocking) and output JSON + tt.test(' setup: triton create ' + INST_ALIAS, function (t) { + var argv = [ + 'create', + '-wj', + '--tag', 'blah=bling', + '-n', INST_ALIAS, + imgId, pkgId + ]; + + var start = Date.now(); + h.safeTriton(t, argv, function (err, stdout) { + var elapsedSec = Math.round((Date.now() - start) / 1000); + t.ok(true, 'created test inst ('+ elapsedSec + 's)'); + var lines = h.jsonStreamParse(stdout); + inst = lines[1]; + t.equal(lines[0].tags.blah, 'bling', '"blah" tag set'); + t.equal(lines[1].state, 'running', 'inst is running'); + t.end(); + }); + }); + + tt.test(' triton inst tag ls INST', function (t) { + h.safeTriton(t, ['inst', 'tag', 'ls', inst.name], + function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, {blah: 'bling'}); + t.end(); + }); + }); + + tt.test(' triton inst tags INST', function (t) { + h.safeTriton(t, ['inst', 'tags', inst.name], function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, {blah: 'bling'}); + t.end(); + }); + }); + + tt.test(' triton inst tag set -w INST name=value', function (t) { + var argv = ['inst', 'tag', 'set', '-w', inst.id, + 'foo=bar', 'pi=3.14', 'really=true']; + h.safeTriton(t, argv, function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, { + blah: 'bling', + foo: 'bar', + pi: 3.14, + really: true + }); + t.end(); + }); + }); + + tt.test(' triton inst get INST foo', function (t) { + h.safeTriton(t, ['inst', 'tag', 'get', inst.id.split('-')[0], 'foo'], + function (err, stdout) { + t.equal(stdout.trim(), 'bar'); + t.end(); + }); + }); + + tt.test(' triton inst get INST foo -j', function (t) { + h.safeTriton(t, ['inst', 'tag', 'get', inst.id, 'foo', '-j'], + function (err, stdout) { + t.equal(stdout.trim(), '"bar"'); + t.end(); + }); + }); + + tt.test(' triton inst get INST really -j', function (t) { + h.safeTriton(t, ['inst', 'tag', 'get', inst.name, 'really', '-j'], + function (err, stdout) { + t.equal(stdout.trim(), 'true'); + t.end(); + }); + }); + + tt.test(' triton inst tag set -w INST -f tags.json', function (t) { + var argv = ['inst', 'tag', 'set', '-w', inst.name, '-f', + path.resolve(__dirname, 'data', 'tags.json')]; + h.safeTriton(t, argv, function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, { + blah: 'bling', + foo: 'bling', + pi: 3.14, + really: true + }); + t.end(); + }); + }); + + tt.test(' triton inst tag set -w INST -f tags.kv', function (t) { + var argv = ['inst', 'tag', 'set', '-w', inst.name, '-f', + path.resolve(__dirname, 'data', 'tags.kv')]; + h.safeTriton(t, argv, function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, { + blah: 'bling', + foo: 'bling', + pi: 3.14, + really: true, + key: 'value', + beep: 'boop' + }); + t.end(); + }); + }); + + tt.test(' triton inst tag rm -w INST key really', function (t) { + var argv = ['inst', 'tag', 'rm', '-w', inst.name, 'key', 'really']; + h.safeTriton(t, argv, function (err, stdout) { + var lines = stdout.trim().split(/\n/); + t.ok(/^Deleted tag key/.test(lines[0]), + 'Deleted tag key ...:' + lines[0]); + t.ok(/^Deleted tag really/.test(lines[1]), + 'Deleted tag really ...:' + lines[1]); + t.end(); + }); + }); + + tt.test(' triton inst tag list INST', function (t) { + var argv = ['inst', 'tag', 'list', inst.name]; + h.safeTriton(t, argv, function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, { + blah: 'bling', + foo: 'bling', + pi: 3.14, + beep: 'boop' + }); + t.end(); + }); + }); + + tt.test(' triton inst tag replace-all -w INST ...', function (t) { + var argv = ['inst', 'tag', 'replace-all', '-w', + inst.name, 'whoa=there']; + h.safeTriton(t, argv, function (err, stdout) { + var tags = JSON.parse(stdout); + t.deepEqual(tags, { + whoa: 'there' + }); + t.end(); + }); + }); + + tt.test(' triton inst tag delete -w -a INST', function (t) { + var argv = ['inst', 'tag', 'delete', '-w', '-a', inst.name]; + h.safeTriton(t, argv, function (err, stdout) { + t.equal(stdout.trim(), 'Deleted all tags on instance ' + inst.name); + t.end(); + }); + }); + + tt.test(' triton inst tags INST', function (t) { + var argv = ['inst', 'tags', inst.name]; + h.safeTriton(t, argv, function (err, stdout) { + t.equal(stdout.trim(), '{}'); + t.end(); + }); + }); + + /* + * Use a timeout, because '-w' on delete doesn't have a way to know if the + * attempt failed or if it is just taking a really long time. + */ + tt.test(' cleanup: triton rm INST', {timeout: 10 * 60 * 1000}, + function (t) { + h.safeTriton(t, ['rm', '-w', inst.id], function (err, stdout) { + t.end(); + }); + }); + +}); diff --git a/test/integration/cli-manage-workflow.test.js b/test/integration/cli-manage-workflow.test.js index a4f06be..f617743 100644 --- a/test/integration/cli-manage-workflow.test.js +++ b/test/integration/cli-manage-workflow.test.js @@ -14,7 +14,6 @@ var f = require('util').format; var os = require('os'); -var tabula = require('tabula'); var test = require('tape'); var vasync = require('vasync'); @@ -24,7 +23,7 @@ var h = require('./helpers'); // --- globals -var INST_ALIAS = f('node-triton-test-%s-vm1', os.hostname()); +var INST_ALIAS = f('nodetritontest-managewf-%s', os.hostname()); var opts = { skip: !h.CONFIG.allowWriteActions @@ -34,21 +33,6 @@ var opts = { var instance; -// --- internal support stuff - -function _jsonStreamParse(s) { - var results = []; - var lines = s.split('\n'); - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (line) { - results.push(JSON.parse(line)); - } - } - return results; -} - - // --- Tests if (opts.skip) { @@ -75,7 +59,7 @@ test('triton manage workflow', opts, function (tt) { } } else { var inst = JSON.parse(stdout); - h.safeTriton(t, ['delete', '-w', inst.id], function () { + h.safeTriton(t, ['inst', 'rm', '-w', inst.id], function () { t.ok(true, 'deleted inst ' + inst.id); t.end(); }); @@ -84,65 +68,25 @@ test('triton manage workflow', opts, function (tt) { }); var imgId; - tt.test(' find image to use', function (t) { - if (h.CONFIG.image) { - imgId = h.CONFIG.image; - t.ok(imgId, 'image from config: ' + imgId); - t.end(); - return; - } - - var candidateImageNames = { - 'base-64-lts': true, - 'base-64': true, - 'minimal-64': true, - 'base-32-lts': true, - 'base-32': true, - 'minimal-32': true, - 'base': true - }; - h.safeTriton(t, ['img', 'ls', '-j'], function (stdout) { - var imgs = _jsonStreamParse(stdout); - // Newest images first. - tabula.sortArrayOfObjects(imgs, ['-published_at']); - var imgRepr; - for (var i = 0; i < imgs.length; i++) { - var img = imgs[i]; - if (candidateImageNames[img.name]) { - imgId = img.id; - imgRepr = f('%s@%s', img.name, img.version); - break; - } - } - - t.ok(imgId, f('latest available base/minimal image: %s (%s)', - imgId, imgRepr)); + tt.test(' setup: find test image', function (t) { + h.getTestImg(t, function (err, imgId_) { + t.ifError(err, 'getTestImg' + (err ? ': ' + err : '')); + imgId = imgId_; t.end(); }); }); var pkgId; - tt.test(' find package to use', function (t) { - if (h.CONFIG.package) { - pkgId = h.CONFIG.package; - t.ok(pkgId, 'package from config: ' + pkgId); - t.end(); - return; - } - - h.safeTriton(t, ['pkg', 'list', '-j'], function (stdout) { - var pkgs = _jsonStreamParse(stdout); - // Smallest RAM first. - tabula.sortArrayOfObjects(pkgs, ['memory']); - pkgId = pkgs[0].id; - t.ok(pkgId, f('smallest (RAM) available package: %s (%s)', - pkgId, pkgs[0].name)); + tt.test(' setup: find test package', function (t) { + h.getTestPkg(t, function (err, pkgId_) { + t.ifError(err, 'getTestPkg' + (err ? ': ' + err : '')); + pkgId = pkgId_; t.end(); }); }); // create a test machine (blocking) and output JSON - tt.test(' triton create', function (t) { + tt.test(' setup: triton create', function (t) { var argv = [ 'create', '-wj', @@ -153,19 +97,8 @@ test('triton manage workflow', opts, function (tt) { imgId, pkgId ]; - h.safeTriton(t, argv, function (stdout) { - // parse JSON response - var lines = stdout.trim().split('\n'); - t.equal(lines.length, 2, 'correct number of JSON lines'); - try { - lines = lines.map(function (line) { - return JSON.parse(line); - }); - } catch (e) { - t.fail('failed to parse JSON'); - t.end(); - } - + h.safeTriton(t, argv, function (err, stdout) { + var lines = h.jsonStreamParse(stdout); instance = lines[1]; t.equal(lines[0].id, lines[1].id, 'correct UUID given'); t.equal(lines[0].metadata.foo, 'bar', 'foo metadata set'); @@ -184,22 +117,13 @@ test('triton manage workflow', opts, function (tt) { vasync.parallel({ funcs: [ function (cb) { - h.safeTriton(t, ['instance', 'get', '-j', INST_ALIAS], - function (stdout) { - cb(null, stdout); - }); + h.safeTriton(t, ['instance', 'get', '-j', INST_ALIAS], cb); }, function (cb) { - h.safeTriton(t, ['instance', 'get', '-j', uuid], - function (stdout) { - cb(null, stdout); - }); + h.safeTriton(t, ['instance', 'get', '-j', uuid], cb); }, function (cb) { - h.safeTriton(t, ['instance', 'get', '-j', shortId], - function (stdout) { - cb(null, stdout); - }); + h.safeTriton(t, ['instance', 'get', '-j', shortId], cb); } ] }, function (err, results) { @@ -229,7 +153,7 @@ test('triton manage workflow', opts, function (tt) { // have a way to know if the attempt failed or if it is just taking a // really long time. tt.test(' triton delete', {timeout: 10 * 60 * 1000}, function (t) { - h.safeTriton(t, ['delete', '-w', instance.id], function (stdout) { + h.safeTriton(t, ['delete', '-w', instance.id], function () { t.end(); }); }); @@ -240,7 +164,7 @@ test('triton manage workflow', opts, function (tt) { // create a test machine (non-blocking) tt.test(' triton create', function (t) { h.safeTriton(t, ['create', '-jn', INST_ALIAS, imgId, pkgId], - function (stdout) { + function (err, stdout) { // parse JSON response var lines = stdout.trim().split('\n'); @@ -263,7 +187,7 @@ test('triton manage workflow', opts, function (tt) { // wait for the machine to start tt.test(' triton inst wait', function (t) { h.safeTriton(t, ['inst', 'wait', instance.id], - function (stdout) { + function (err, stdout) { // parse JSON response var lines = stdout.trim().split('\n'); @@ -280,8 +204,7 @@ test('triton manage workflow', opts, function (tt) { // stop the machine tt.test(' triton stop', function (t) { - h.safeTriton(t, ['stop', '-w', INST_ALIAS], - function (stdout) { + h.safeTriton(t, ['stop', '-w', INST_ALIAS], function (err, stdout) { t.ok(stdout.match(/^Stop instance/, 'correct stdout')); t.end(); }); @@ -290,11 +213,9 @@ test('triton manage workflow', opts, function (tt) { // wait for the machine to stop tt.test(' triton confirm stopped', function (t) { h.safeTriton(t, {json: true, args: ['inst', 'get', '-j', INST_ALIAS]}, - function (d) { + function (err, d) { instance = d; - t.equal(d.state, 'stopped', 'machine stopped'); - t.end(); }); }); @@ -302,7 +223,7 @@ test('triton manage workflow', opts, function (tt) { // start the machine tt.test(' triton start', function (t) { h.safeTriton(t, ['start', '-w', INST_ALIAS], - function (stdout) { + function (err, stdout) { t.ok(stdout.match(/^Start instance/, 'correct stdout')); t.end(); }); @@ -311,7 +232,7 @@ test('triton manage workflow', opts, function (tt) { // wait for the machine to start tt.test(' confirm running', function (t) { h.safeTriton(t, {json: true, args: ['inst', 'get', '-j', INST_ALIAS]}, - function (d) { + function (err, d) { instance = d; t.equal(d.state, 'running', 'machine running'); t.end(); @@ -320,7 +241,7 @@ test('triton manage workflow', opts, function (tt) { // remove test instance tt.test(' cleanup (triton delete)', function (t) { - h.safeTriton(t, ['delete', '-w', instance.id], function (stdout) { + h.safeTriton(t, ['delete', '-w', instance.id], function () { t.end(); }); }); diff --git a/test/integration/cli-profiles.test.js b/test/integration/cli-profiles.test.js index 00982d6..1f01596 100644 --- a/test/integration/cli-profiles.test.js +++ b/test/integration/cli-profiles.test.js @@ -5,7 +5,7 @@ */ /* - * Copyright (c) 2015, Joyent, Inc. + * Copyright 2016, Joyent, Inc. */ /* @@ -36,7 +36,7 @@ if (opts.skip) { test('triton profiles (read only)', function (tt) { tt.test(' triton profile get env', function (t) { h.safeTriton(t, {json: true, args: ['profile', 'get', '-j', 'env']}, - function (p) { + function (err, p) { t.equal(p.account, h.CONFIG.profile.account, 'env account correct'); @@ -55,8 +55,7 @@ test('triton profiles (read only)', function (tt) { test('triton profiles (read/write)', opts, function (tt) { tt.test(' triton profile create', function (t) { h.safeTriton(t, ['profile', 'create', '-f', PROFILE_FILE], - function (stdout) { - + function (err, stdout) { t.ok(stdout.match(/^Saved profile/), 'stdout correct'); t.end(); }); @@ -65,17 +64,16 @@ test('triton profiles (read/write)', opts, function (tt) { tt.test(' triton profile get', function (t) { h.safeTriton(t, {json: true, args: ['profile', 'get', '-j', PROFILE_DATA.name]}, - function (p) { + function (err, p) { t.deepEqual(p, PROFILE_DATA, 'profile matched'); - t.end(); }); }); tt.test(' triton profile delete', function (t) { h.safeTriton(t, ['profile', 'delete', '-f', PROFILE_DATA.name], - function (stdout) { + function (err, stdout) { t.ok(stdout.match(/^Deleted profile/), 'stdout correct'); t.end(); diff --git a/test/integration/data/tags.json b/test/integration/data/tags.json new file mode 100644 index 0000000..d879a27 --- /dev/null +++ b/test/integration/data/tags.json @@ -0,0 +1 @@ +{"foo": "bling"} diff --git a/test/integration/data/tags.kv b/test/integration/data/tags.kv new file mode 100644 index 0000000..e47cd77 --- /dev/null +++ b/test/integration/data/tags.kv @@ -0,0 +1,2 @@ +key=value +beep=boop diff --git a/test/integration/helpers.js b/test/integration/helpers.js index e7de302..628a740 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -16,6 +16,7 @@ var error = console.error; var assert = require('assert-plus'); var f = require('util').format; var path = require('path'); +var tabula = require('tabula'); var common = require('../../lib/common'); var mod_triton = require('../../'); @@ -124,29 +125,28 @@ function triton(args, opts, cb) { /* - * triton wrapper that: - * - tests no error is present - * - tests stdout is not empty + * `triton ...` wrapper that: + * - tests non-error exit * - tests stderr is empty * - * In the event that any of the above is false, this function will NOT - * fire the callback, which will result in the early terminate of these - * tests as `t.end()` will never be called. - * * @param {Tape} t - tape test object - * @param {Object|Array} opts - options object - * @param {Function} cb - callback called like "cb(stdout)" + * @param {Object|Array} opts - options object, or just the `triton` args + * @param {Function} cb - `function (err, stdout)` */ function safeTriton(t, opts, cb) { + assert.object(t, 't'); if (Array.isArray(opts)) { opts = {args: opts}; } + assert.object(opts, 'opts'); + assert.arrayOfString(opts.args, 'opts.args'); + assert.optionalBool(opts.json, 'opts.json'); + assert.func(cb, 'cb'); + t.comment(f('running: triton %s', opts.args.join(' '))); triton(opts.args, function (err, stdout, stderr) { t.error(err, 'no error running child process'); t.equal(stderr, '', 'no stderr produced'); - t.notEqual(stdout, '', 'stdout produced'); - if (opts.json) { try { stdout = JSON.parse(stdout); @@ -155,13 +155,96 @@ function safeTriton(t, opts, cb) { return; } } - - if (!err && stdout && !stderr) - cb(stdout); + cb(err, stdout); }); } +/* + * Find and return an image that can be used for test provisions. We look + * for an available base or minimal image. + * + * @param {Tape} t - tape test object + * @param {Function} cb - `function (err, imgId)` + * where `imgId` is an image identifier (an image name, shortid, or id). + */ +function getTestImg(t, cb) { + if (CONFIG.image) { + t.ok(CONFIG.image, 'image from config: ' + CONFIG.image); + cb(null, CONFIG.image); + return; + } + + var candidateImageNames = { + 'base-64-lts': true, + 'base-64': true, + 'minimal-64': true, + 'base-32-lts': true, + 'base-32': true, + 'minimal-32': true, + 'base': true + }; + safeTriton(t, ['img', 'ls', '-j'], function (err, stdout) { + var imgId; + var imgs = jsonStreamParse(stdout); + // Newest images first. + tabula.sortArrayOfObjects(imgs, ['-published_at']); + var imgRepr; + for (var i = 0; i < imgs.length; i++) { + var img = imgs[i]; + if (candidateImageNames[img.name]) { + imgId = img.id; + imgRepr = f('%s@%s', img.name, img.version); + break; + } + } + + t.ok(imgId, f('latest available base/minimal image: %s (%s)', + imgId, imgRepr)); + cb(err, imgId); + }); +} + + +/* + * Find and return an package that can be used for test provisions. + * + * @param {Tape} t - tape test object + * @param {Function} cb - `function (err, pkgId)` + * where `pkgId` is an package identifier (a name, shortid, or id). + */ +function getTestPkg(t, cb) { + if (CONFIG.package) { + t.ok(CONFIG.package, 'package from config: ' + CONFIG.package); + cb(null, CONFIG.package); + return; + } + + safeTriton(t, ['pkg', 'ls', '-j'], function (err, stdout) { + var pkgs = jsonStreamParse(stdout); + // Smallest RAM first. + tabula.sortArrayOfObjects(pkgs, ['memory']); + var pkgId = pkgs[0].id; + t.ok(pkgId, f('smallest (RAM) available package: %s (%s)', + pkgId, pkgs[0].name)); + cb(null, pkgId); + }); +} + + +function jsonStreamParse(s) { + var results = []; + var lines = s.trim().split('\n'); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (line) { + results.push(JSON.parse(line)); + } + } + return results; +} + + /* * Create a TritonApi client using the CLI. */ @@ -230,5 +313,9 @@ module.exports = { safeTriton: safeTriton, createClient: createClient, createMachine: createMachine, + getTestImg: getTestImg, + getTestPkg: getTestPkg, + jsonStreamParse: jsonStreamParse, + ifErr: testcommon.ifErr }; diff --git a/test/unit/tagsFromOpts.test.js b/test/unit/tagsFromCreateOpts.test.js similarity index 95% rename from test/unit/tagsFromOpts.test.js rename to test/unit/tagsFromCreateOpts.test.js index 12a82e4..e58f61f 100644 --- a/test/unit/tagsFromOpts.test.js +++ b/test/unit/tagsFromCreateOpts.test.js @@ -9,7 +9,7 @@ */ /* - * Unit tests for `tagsFromOpts()` used by `triton create ...`. + * Unit tests for `tagsFromCreateOpts()` used by `triton create ...`. */ var assert = require('assert-plus'); @@ -17,7 +17,8 @@ var cmdln = require('cmdln'); var format = require('util').format; var test = require('tape'); -var tagsFromOpts = require('../../lib/metadataandtags').tagsFromOpts; +var tagsFromCreateOpts + = require('../../lib/metadataandtags').tagsFromCreateOpts; // ---- globals @@ -140,7 +141,7 @@ var cases = [ // ---- test driver -test('tagsFromOpts', function (tt) { +test('tagsFromCreateOpts', function (tt) { cases.forEach(function (c, num) { var testName = format('case %d: %s', num, c.argv.join(' ')); tt.test(testName, function (t) { @@ -157,7 +158,7 @@ test('tagsFromOpts', function (tt) { stderrChunks.push(s); }; - tagsFromOpts(opts, log, function (err, tags) { + tagsFromCreateOpts(opts, log, function (err, tags) { // Restore stderr. process.stderr.write = _oldStderrWrite; var stderr = stderrChunks.join(''); diff --git a/test/unit/tagsFromSetArgs.test.js b/test/unit/tagsFromSetArgs.test.js new file mode 100644 index 0000000..e58f61f --- /dev/null +++ b/test/unit/tagsFromSetArgs.test.js @@ -0,0 +1,197 @@ +/* + * 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) 2015, Joyent, Inc. + */ + +/* + * Unit tests for `tagsFromCreateOpts()` used by `triton create ...`. + */ + +var assert = require('assert-plus'); +var cmdln = require('cmdln'); +var format = require('util').format; +var test = require('tape'); + +var tagsFromCreateOpts + = require('../../lib/metadataandtags').tagsFromCreateOpts; + + +// ---- globals + +var log = require('../lib/log'); + +var debug = function () {}; +// debug = console.warn; + + +// ---- test cases + +var OPTIONS = [ + { + names: ['tag', 't'], + type: 'arrayOfString' + } +]; + +var cases = [ + { + argv: ['triton', 'create', '-t', 'foo=bar'], + expect: { + tags: {foo: 'bar'} + } + }, + { + argv: ['triton', 'create', '--tag', 'foo=bar'], + expect: { + tags: {foo: 'bar'} + } + }, + { + argv: ['triton', 'create', '-t', 'foo=bar', '-t', 'bling=bloop'], + expect: { + tags: { + foo: 'bar', + bling: 'bloop' + } + } + }, + { + argv: ['triton', 'create', + '-t', 'num=42', + '-t', 'pi=3.14', + '-t', 'yes=true', + '-t', 'no=false', + '-t', 'array=[1,2,3]'], + expect: { + tags: { + num: 42, + pi: 3.14, + yes: true, + no: false, + array: '[1,2,3]' + } + } + }, + + { + argv: ['triton', 'create', + '-t', '@' + __dirname + '/corpus/metadata.json'], + expect: { + tags: { + 'foo': 'bar', + 'one': 'four', + 'num': 42 + } + } + }, + { + argv: ['triton', 'create', + '-t', '@' + __dirname + '/corpus/metadata.kv'], + expect: { + tags: { + 'foo': 'bar', + 'one': 'four', + 'num': 42 + } + } + }, + { + argv: ['triton', 'create', + '-t', '@' + __dirname + '/corpus/metadata-illegal-types.json'], + expect: { + err: [ + /* jsl:ignore */ + /invalid tag value type/, + /\(from .*corpus\/metadata-illegal-types.json\)/, + /must be one of string/ + /* jsl:end */ + ] + } + }, + { + argv: ['triton', 'create', + '-t', '@' + __dirname + '/corpus/metadata-invalid-json.json'], + expect: { + err: [ + /* jsl:ignore */ + /is not valid JSON/, + /corpus\/metadata-invalid-json.json/ + /* jsl:end */ + ] + } + }, + + { + argv: ['triton', 'create', + '-t', '{"foo":"bar","num":12}'], + expect: { + tags: { + 'foo': 'bar', + 'num': 12 + } + } + } +]; + + +// ---- test driver + +test('tagsFromCreateOpts', function (tt) { + cases.forEach(function (c, num) { + var testName = format('case %d: %s', num, c.argv.join(' ')); + tt.test(testName, function (t) { + debug('--', num); + debug('c: %j', c); + var parser = new cmdln.dashdash.Parser({options: OPTIONS}); + var opts = parser.parse({argv: c.argv}); + debug('opts: %j', opts); + + // Capture stderr for warnings while running. + var stderrChunks = []; + var _oldStderrWrite = process.stderr.write; + process.stderr.write = function (s) { + stderrChunks.push(s); + }; + + tagsFromCreateOpts(opts, log, function (err, tags) { + // Restore stderr. + process.stderr.write = _oldStderrWrite; + var stderr = stderrChunks.join(''); + + if (c.expect.err) { + var errRegexps = (Array.isArray(c.expect.err) + ? c.expect.err : [c.expect.err]); + errRegexps.forEach(function (regexp) { + assert.regexp(regexp, 'case.expect.err'); + t.ok(err, 'expected an error'); + t.ok(regexp.test(err.message), format( + 'error message matches %s, actual %j', + regexp, err.message)); + }); + } else { + t.ifError(err); + } + if (c.expect.hasOwnProperty('tags')) { + t.deepEqual(tags, c.expect.tags); + } + if (c.expect.hasOwnProperty('stderr')) { + var stderrRegexps = (Array.isArray(c.expect.stderr) + ? c.expect.stderr : [c.expect.stderr]); + stderrRegexps.forEach(function (regexp) { + assert.regexp(regexp, 'case.expect.stderr'); + t.ok(regexp.test(stderr), format( + 'error message matches %s, actual %j', + regexp, stderr)); + }); + + } + t.end(); + }); + }); + }); +}); diff --git a/tools/cutarelease.py b/tools/cutarelease.py index e4c5769..74f1a74 100755 --- a/tools/cutarelease.py +++ b/tools/cutarelease.py @@ -26,6 +26,7 @@ import codecs import logging import optparse import json +import time @@ -159,7 +160,8 @@ def cutarelease(project_name, version_files, dry_run=False): curr_tags = set(t for t in _capture_stdout(["git", "tag", "-l"]).split('\n') if t) if not dry_run and version not in curr_tags: log.info("tag the release") - run('git tag -a "%s" -m "version %s"' % (version, version)) + date = time.strftime("%Y-%m-%d") + run('git tag -a "%s" -m "version %s (%s)"' % (version, version, date)) run('git push --tags') # Optionally release. diff --git a/tools/jsl.node.conf b/tools/jsl.node.conf index db416be..7cd6a78 100644 --- a/tools/jsl.node.conf +++ b/tools/jsl.node.conf @@ -119,6 +119,7 @@ +define module +define process +define require ++define setImmediate +define setInterval +define setTimeout +define Buffer