'triton image create' et al
Fixes #76: `triton image create ...` and `triton image wait ...` Fixes #72: want `triton image` to still return image details even when it is not in 'active' state
This commit is contained in:
		
							parent
							
								
									d199ed8503
								
							
						
					
					
						commit
						8d235b8e28
					
				| @ -1,8 +1,9 @@ | ||||
| # node-triton changelog | ||||
| 
 | ||||
| ## 4.2.1 (not yet released) | ||||
| ## 4.3.0 (not yet released) | ||||
| 
 | ||||
| (nothing yet) | ||||
| - #76 `triton image create ...` and `triton image wait ...` | ||||
| - #72 want `triton image` to still return image details even when it is not in 'active' state  | ||||
| 
 | ||||
| 
 | ||||
| ## 4.2.0 | ||||
|  | ||||
							
								
								
									
										102
									
								
								lib/cloudapi2.js
									
									
									
									
									
								
							
							
						
						
									
										102
									
								
								lib/cloudapi2.js
									
									
									
									
									
								
							| @ -540,6 +540,85 @@ CloudApi.prototype.getImage = function getImage(opts, cb) { | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * <http://apidocs.joyent.com/cloudapi/#CreateImageFromMachine>
 | ||||
|  * | ||||
|  * @param {Object} opts | ||||
|  *      - {UUID} machine  Required. The ID of the machine from which to create | ||||
|  *        the image. | ||||
|  *      - {String} name  Required. The image name. | ||||
|  *      - {String} version  Required. The image version. | ||||
|  *      - {String} description  Optional. A short description. | ||||
|  *      - {String} homepage  Optional. Homepage URL. | ||||
|  *      - {String} eula  Optional. EULA URL. | ||||
|  *      - {Array} acl  Optional. An array of account UUIDs to which to give | ||||
|  *        access. "Access Control List." | ||||
|  *      - {Object} tags  Optional. | ||||
|  * @param {Function} cb of the form `function (err, image, res)` | ||||
|  */ | ||||
| CloudApi.prototype.createImageFromMachine = | ||||
| function createImageFromMachine(opts, cb) { | ||||
|     assert.object(opts, 'opts'); | ||||
|     assert.uuid(opts.machine, 'opts.machine'); | ||||
|     assert.string(opts.name, 'opts.name'); | ||||
|     assert.string(opts.version, 'opts.version'); | ||||
|     assert.optionalString(opts.description, 'opts.description'); | ||||
|     assert.optionalString(opts.homepage, 'opts.homepage'); | ||||
|     assert.optionalString(opts.eula, 'opts.eula'); | ||||
|     assert.optionalArrayOfUuid(opts.acl, 'opts.acl'); | ||||
|     assert.optionalObject(opts.tags, 'opts.tags'); | ||||
|     assert.func(cb, 'cb'); | ||||
| 
 | ||||
|     this._request({ | ||||
|         method: 'POST', | ||||
|         path: format('/%s/images', this.account), | ||||
|         data: opts | ||||
|     }, function (err, req, res, body) { | ||||
|         cb(err, body, res); | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Wait for an image 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. Default is 1000ms. | ||||
|  * @param {Function} cb - `function (err, image, res)` | ||||
|  *      Called when state is reached or on error | ||||
|  */ | ||||
| CloudApi.prototype.waitForImageStates = | ||||
| function waitForImageStates(opts, cb) { | ||||
|     var self = this; | ||||
|     assert.object(opts, 'opts'); | ||||
|     assert.uuid(opts.id, 'opts.id'); | ||||
|     assert.arrayOfString(opts.states, 'opts.states'); | ||||
|     assert.optionalNumber(opts.interval, 'opts.interval'); | ||||
|     assert.func(cb, 'cb'); | ||||
|     var interval = (opts.interval === undefined ? 1000 : opts.interval); | ||||
|     assert.ok(interval > 0, 'interval must be a positive number'); | ||||
| 
 | ||||
|     poll(); | ||||
| 
 | ||||
|     function poll() { | ||||
|         self.getImage({id: opts.id}, function (err, img, res) { | ||||
|             if (err) { | ||||
|                 cb(err, null, res); | ||||
|                 return; | ||||
|             } | ||||
|             if (opts.states.indexOf(img.state) !== -1) { | ||||
|                 cb(null, img, res); | ||||
|                 return; | ||||
|             } | ||||
|             setTimeout(poll, interval); | ||||
|         }); | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| // ---- packages
 | ||||
| 
 | ||||
| /** | ||||
| @ -583,14 +662,20 @@ CloudApi.prototype.getPackage = function getPackage(opts, cb) { | ||||
|  * XXX add getCredentials equivalent | ||||
|  * XXX cloudapi docs don't doc the credentials=true option | ||||
|  * | ||||
|  * @param {String} uuid (required) The machine id. | ||||
|  * @param {Function} callback of the form `function (err, machine, res)` | ||||
|  * For backwards compat, calling with `getMachine(id, cb)` is allowed. | ||||
|  * | ||||
|  * @param {Object} opts | ||||
|  *      - id {UUID} Required. The machine id. | ||||
|  * @param {Function} cb of the form `function (err, machine, res)` | ||||
|  */ | ||||
| CloudApi.prototype.getMachine = function getMachine(id, cb) { | ||||
|     assert.uuid(id, 'id'); | ||||
|     assert.func(cb, 'cb'); | ||||
| CloudApi.prototype.getMachine = function getMachine(opts, cb) { | ||||
|     if (typeof (opts) === 'string') { | ||||
|         opts = {id: opts}; | ||||
|     } | ||||
|     assert.object(opts, 'opts'); | ||||
|     assert.uuid(opts.id, 'opts.id'); | ||||
| 
 | ||||
|     var endpoint = format('/%s/machines/%s', this.account, id); | ||||
|     var endpoint = format('/%s/machines/%s', this.account, opts.id); | ||||
|     this._request(endpoint, function (err, req, res, body) { | ||||
|         cb(err, body, res); | ||||
|     }); | ||||
| @ -677,14 +762,15 @@ CloudApi.prototype._doMachine = function _doMachine(action, uuid, callback) { | ||||
|  * @param {Function} callback - called when state is reached or on error | ||||
|  */ | ||||
| CloudApi.prototype.waitForMachineStates = | ||||
|         function waitForMachineStates(opts, callback) { | ||||
| function waitForMachineStates(opts, callback) { | ||||
|     var self = this; | ||||
|     assert.object(opts, 'opts'); | ||||
|     assert.string(opts.id, 'opts.id'); | ||||
|     assert.uuid(opts.id, 'opts.id'); | ||||
|     assert.arrayOfString(opts.states, 'opts.states'); | ||||
|     assert.optionalNumber(opts.interval, 'opts.interval'); | ||||
|     assert.func(callback, 'callback'); | ||||
|     var interval = (opts.interval === undefined ? 1000 : opts.interval); | ||||
|     assert.ok(interval > 0, 'interval must be a positive number'); | ||||
| 
 | ||||
|     poll(); | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										270
									
								
								lib/do_image/do_create.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										270
									
								
								lib/do_image/do_create.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,270 @@ | ||||
| /* | ||||
|  * 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 2015 Joyent, Inc. | ||||
|  * | ||||
|  * `triton image create ...` | ||||
|  */ | ||||
| 
 | ||||
| var assert = require('assert-plus'); | ||||
| var format = require('util').format; | ||||
| var fs = require('fs'); | ||||
| var strsplit = require('strsplit'); | ||||
| var tabula = require('tabula'); | ||||
| var tilde = require('tilde-expansion'); | ||||
| var vasync = require('vasync'); | ||||
| 
 | ||||
| var common = require('../common'); | ||||
| var distractions = require('../distractions'); | ||||
| var errors = require('../errors'); | ||||
| var mat = require('../metadataandtags'); | ||||
| 
 | ||||
| 
 | ||||
| // ---- the command
 | ||||
| 
 | ||||
| function do_create(subcmd, opts, args, cb) { | ||||
|     var self = this; | ||||
|     if (opts.help) { | ||||
|         this.do_help('help', {}, [subcmd], cb); | ||||
|         return; | ||||
|     } else if (args.length !== 3) { | ||||
|         cb(new errors.UsageError( | ||||
|             'incorrect number of args: expect 3, got ' + args.length)); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     var log = this.top.log; | ||||
|     var cloudapi = this.top.tritonapi.cloudapi; | ||||
| 
 | ||||
|     vasync.pipeline({arg: {}, funcs: [ | ||||
|         function loadTags(ctx, next) { | ||||
|             mat.tagsFromOpts(opts, log, function (err, tags) { | ||||
|                 if (err) { | ||||
|                     next(err); | ||||
|                     return; | ||||
|                 } | ||||
|                 if (tags) { | ||||
|                     log.trace({tags: tags}, 'tags loaded from opts'); | ||||
|                     ctx.tags = tags; | ||||
|                 } | ||||
|                 next(); | ||||
|             }); | ||||
|         }, | ||||
|         function loadAcl(ctx, next) { | ||||
|             if (!opts.acl) { | ||||
|                 next(); | ||||
|                 return; | ||||
|             } | ||||
|             for (var i = 0; i < opts.acl.length; i++) { | ||||
|                 if (!common.isUUID(opts.acl[i])) { | ||||
|                     next(new errors.UsageError(format( | ||||
|                         'invalid --acl: "%s" is not a UUID', opts.acl[i]))); | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|             ctx.acl = opts.acl; | ||||
|             next(); | ||||
|         }, | ||||
|         function getInst(ctx, next) { | ||||
|             var id = args[0]; | ||||
|             if (common.isUUID(id)) { | ||||
|                 ctx.inst = {id: id}; | ||||
|                 next(); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             self.top.tritonapi.getInstance(id, function (err, inst) { | ||||
|                 if (err) { | ||||
|                     next(err); | ||||
|                     return; | ||||
|                 } | ||||
|                 log.trace({inst: inst}, 'image create: inst'); | ||||
|                 ctx.inst = inst; | ||||
|                 next(); | ||||
|             }); | ||||
|         }, | ||||
|         function createImg(ctx, next) { | ||||
|             var createOpts = { | ||||
|                 machine: ctx.inst.id, | ||||
|                 name: args[1], | ||||
|                 version: args[2], | ||||
|                 description: opts.description, | ||||
|                 homepage: opts.homepage, | ||||
|                 eula: opts.eula, | ||||
|                 acl: ctx.acl, | ||||
|                 tags: ctx.tags | ||||
|             }; | ||||
| 
 | ||||
|             log.trace({dryRun: opts.dry_run, createOpts: createOpts}, | ||||
|                 'image create createOpts'); | ||||
|             ctx.start = Date.now(); | ||||
|             if (opts.dry_run) { | ||||
|                 ctx.inst = { | ||||
|                     id: 'cafecafe-4c0e-11e5-86cd-a7fd38d2a50b', | ||||
|                     name: 'this-is-a-dry-run' | ||||
|                 }; | ||||
|                 console.log('Creating image %s@%s from instance %s%s', | ||||
|                     createOpts.name, createOpts.version, ctx.inst.id, | ||||
|                     (ctx.inst.name ? ' ('+ctx.inst.name+')' : '')); | ||||
|                 next(); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             cloudapi.createImageFromMachine(createOpts, function (err, img) { | ||||
|                 if (err) { | ||||
|                     next(new errors.TritonError(err, 'error creating image')); | ||||
|                     return; | ||||
|                 } | ||||
|                 ctx.img = img; | ||||
|                 if (opts.json) { | ||||
|                     console.log(JSON.stringify(img)); | ||||
|                 } else { | ||||
|                     console.log('Creating image %s@%s (%s)', | ||||
|                         img.name, img.version, img.id); | ||||
|                 } | ||||
|                 next(); | ||||
|             }); | ||||
|         }, | ||||
|         function maybeWait(ctx, next) { | ||||
|             if (!opts.wait) { | ||||
|                 return next(); | ||||
|             } | ||||
| 
 | ||||
|             //  1 'wait': no distraction.
 | ||||
|             // >1 'wait': distraction, pass in the N.
 | ||||
|             var distraction; | ||||
|             if (process.stderr.isTTY && opts.wait.length > 1) { | ||||
|                 distraction = distractions.createDistraction(opts.wait.length); | ||||
|             } | ||||
| 
 | ||||
|             // Dry-run: fake wait for a few seconds.
 | ||||
|             var waiter = (opts.dry_run ? | ||||
|                 function dryWait(waitOpts, waitCb) { | ||||
|                     setTimeout(function () { | ||||
|                         ctx.img.state = 'running'; | ||||
|                         waitCb(null, ctx.img); | ||||
|                     }, 5000); | ||||
|                 } | ||||
|                 : cloudapi.waitForImageStates.bind(cloudapi)); | ||||
| 
 | ||||
|             waiter({ | ||||
|                 id: ctx.img.id, | ||||
|                 states: ['active', 'failed'] | ||||
|             }, function (err, img) { | ||||
|                 if (distraction) { | ||||
|                     distraction.destroy(); | ||||
|                 } | ||||
|                 if (err) { | ||||
|                     return next(err); | ||||
|                 } | ||||
|                 if (opts.json) { | ||||
|                     console.log(JSON.stringify(img)); | ||||
|                 } else if (img.state === 'active') { | ||||
|                     var dur = Date.now() - ctx.start; | ||||
|                     console.log('Created image %s (%s@%s) in %s', | ||||
|                         img.id, img.name, img.version, | ||||
|                         common.humanDurationFromMs(dur)); | ||||
|                 } | ||||
|                 if (img.state !== 'active') { | ||||
|                     next(new Error(format('failed to create image %s (%s@%s)%s', | ||||
|                         img.id, img.name, img.version, | ||||
|                         (img.error ? format(': (%s) %s', | ||||
|                             img.error.code, img.error.message): '')))); | ||||
|                 } else { | ||||
|                     next(); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     ]}, function (err) { | ||||
|         cb(err); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| do_create.options = [ | ||||
|     { | ||||
|         names: ['help', 'h'], | ||||
|         type: 'bool', | ||||
|         help: 'Show this help.' | ||||
|     }, | ||||
|     { | ||||
|         group: 'Create options' | ||||
|     }, | ||||
|     { | ||||
|         names: ['description', 'd'], | ||||
|         type: 'string', | ||||
|         helpArg: 'DESC', | ||||
|         help: 'A short description of the image.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['homepage'], | ||||
|         type: 'string', | ||||
|         helpArg: 'URL', | ||||
|         help: 'A homepage URL for the image.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['eula'], | ||||
|         type: 'string', | ||||
|         helpArg: 'DESC', | ||||
|         help: 'A URL for an End User License Agreement (EULA) for the image.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['acl'], | ||||
|         type: 'arrayOfString', | ||||
|         helpArg: 'ID', | ||||
|         help: 'Access Control List. The ID of an account to which to give ' + | ||||
|             'access to this private image. This option can be used multiple ' + | ||||
|             'times to give access to multiple accounts.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['tag', 't'], | ||||
|         type: 'arrayOfString', | ||||
|         helpArg: 'TAG', | ||||
|         help: 'Add a tag when creating the image. Tags are ' + | ||||
|             'key/value pairs available on the image API object as the ' + | ||||
|             '"tags" field. TAG is one of: a "key=value" string (bool and ' + | ||||
|             'numeric "value" are converted to that type), a JSON object ' + | ||||
|             '(if first char is "{"), or a "@FILE" to have tags be ' + | ||||
|             'loaded from FILE. This option can be used multiple times.' | ||||
|     }, | ||||
|     { | ||||
|         group: 'Other options' | ||||
|     }, | ||||
|     { | ||||
|         names: ['dry-run'], | ||||
|         type: 'bool', | ||||
|         help: 'Go through the motions without actually creating.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['wait', 'w'], | ||||
|         type: 'arrayOfBool', | ||||
|         help: 'Wait for the creation to complete. Use multiple times for a ' + | ||||
|             'spinner.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['json', 'j'], | ||||
|         type: 'bool', | ||||
|         help: 'JSON stream output.' | ||||
|     } | ||||
| ]; | ||||
| 
 | ||||
| do_create.help = ( | ||||
|     /* BEGIN JSSTYLED */ | ||||
|     'Create a new instance.\n' + | ||||
|     '\n' + | ||||
|     'Usage:\n' + | ||||
|     '    {{name}} create [<options>] INSTANCE IMAGE-NAME IMAGE-VERSION\n' + | ||||
|     '\n' + | ||||
|     '{{options}}' | ||||
|     /* END JSSTYLED */ | ||||
| ); | ||||
| 
 | ||||
| do_create.helpOpts = { | ||||
|     maxHelpCol: 20 | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| module.exports = do_create; | ||||
							
								
								
									
										142
									
								
								lib/do_image/do_wait.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								lib/do_image/do_wait.js
									
									
									
									
									
										Normal 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 image wait ...` | ||||
|  */ | ||||
| 
 | ||||
| var vasync = require('vasync'); | ||||
| 
 | ||||
| var distractions = require('../distractions'); | ||||
| var errors = require('../errors'); | ||||
| 
 | ||||
| 
 | ||||
| function do_wait(subcmd, opts, args, cb) { | ||||
|     var self = this; | ||||
|     if (opts.help) { | ||||
|         return this.do_help('help', {}, [subcmd], cb); | ||||
|     } else if (args.length < 1) { | ||||
|         return cb(new errors.UsageError('missing IMAGE arg(s)')); | ||||
|     } | ||||
|     var ids = args; | ||||
|     var states = []; | ||||
|     opts.states.forEach(function (s) { | ||||
|         /* JSSTYLED */ | ||||
|         states = states.concat(s.trim().split(/\s*,\s*/g)); | ||||
|     }); | ||||
| 
 | ||||
|     var distraction; | ||||
|     var done = 0; | ||||
|     var imgFromId = {}; | ||||
| 
 | ||||
|     vasync.pipeline({funcs: [ | ||||
|         function getImgs(_, next) { | ||||
|             vasync.forEachParallel({ | ||||
|                 inputs: ids, | ||||
|                 func: function getImg(id, nextImg) { | ||||
|                     self.top.tritonapi.getImage(id, function (err, img) { | ||||
|                         if (err) { | ||||
|                             return nextImg(err); | ||||
|                         } | ||||
|                         if (states.indexOf(img.state) !== -1) { | ||||
|                             console.log('%d/%d: Image %s (%s@%s) already %s', | ||||
|                                 ++done, ids.length, img.id, img.name, | ||||
|                                 img.version, img.state); | ||||
|                         } else { | ||||
|                             imgFromId[img.id] = img; | ||||
|                         } | ||||
|                         nextImg(); | ||||
|                     }); | ||||
|                 } | ||||
|             }, next); | ||||
|         }, | ||||
| 
 | ||||
|         function waitForImgs(_, next) { | ||||
|             var idsToWaitFor = Object.keys(imgFromId); | ||||
|             if (idsToWaitFor.length === 0) { | ||||
|                 return next(); | ||||
|             } | ||||
| 
 | ||||
|             if (idsToWaitFor.length === 1) { | ||||
|                 var img2 = imgFromId[idsToWaitFor[0]]; | ||||
|                 console.log( | ||||
|                     'Waiting for image %s (%s@%s) to enter state (states: %s)', | ||||
|                     img2.id, img2.name, img2.version, states.join(', ')); | ||||
|             } else { | ||||
|                 console.log( | ||||
|                     'Waiting for %d images to enter state (states: %s)', | ||||
|                     idsToWaitFor.length, states.join(', ')); | ||||
|             } | ||||
| 
 | ||||
|             /* | ||||
|              * TODO: need BigSpinner.log first. | ||||
|              * TODO: Also when adding a spinner, we need an equiv option to | ||||
|              * `triton create -wwww` to trigger the spinner (and size). By | ||||
|              * default: no spinner. | ||||
|              */ | ||||
|             if (false && | ||||
|                 process.stderr.isTTY) | ||||
|             { | ||||
|                 distraction = distractions.createDistraction(); | ||||
|             } | ||||
| 
 | ||||
|             vasync.forEachParallel({ | ||||
|                 inputs: idsToWaitFor, | ||||
|                 func: function waitForImg(id, nextImg) { | ||||
|                     self.top.tritonapi.cloudapi.waitForImageStates({ | ||||
|                         id: id, | ||||
|                         states: states | ||||
|                     }, function (err, img, res) { | ||||
|                         if (err) { | ||||
|                             return nextImg(err); | ||||
|                         } | ||||
|                         console.log('%d/%d: Image %s (%s@%s) moved to state %s', | ||||
|                             ++done, ids.length, img.id, img.name, | ||||
|                             img.version, img.state); | ||||
|                         nextImg(); | ||||
|                     }); | ||||
|                 } | ||||
|             }, next); | ||||
|         } | ||||
| 
 | ||||
|     ]}, function (err) { | ||||
|         if (distraction) { | ||||
|             distraction.destroy(); | ||||
|         } | ||||
|         cb(err); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| do_wait.help = [ | ||||
|     'Wait for images to change to a particular state.', | ||||
|     '', | ||||
|     'Usage:', | ||||
|     '       {{name}} wait [-s STATES] IMAGE [IMAGE ...]', | ||||
|     '', | ||||
|     '{{options}}', | ||||
|     'Where "states" is a comma-separated list of target instance states,', | ||||
|     'by default "active,failed". In other words, "triton img wait foo0" will', | ||||
|     'wait for image "foo0" to complete creation.' | ||||
| ].join('\n'); | ||||
| do_wait.options = [ | ||||
|     { | ||||
|         names: ['help', 'h'], | ||||
|         type: 'bool', | ||||
|         help: 'Show this help.' | ||||
|     }, | ||||
|     { | ||||
|         names: ['states', 's'], | ||||
|         type: 'arrayOfString', | ||||
|         default: ['active', 'failed'], | ||||
|         helpArg: 'STATES', | ||||
|         help: 'Instance states on which to wait. Default is "active,failed". ' | ||||
|             + 'Values can be comma-separated or multiple uses of the option.' | ||||
|     } | ||||
| ]; | ||||
| 
 | ||||
| module.exports = do_wait; | ||||
| @ -32,7 +32,9 @@ function ImageCLI(top) { | ||||
|         helpSubcmds: [ | ||||
|             'help', | ||||
|             'list', | ||||
|             'get' | ||||
|             'get', | ||||
|             'create', | ||||
|             'wait' | ||||
|         ] | ||||
|     }); | ||||
| } | ||||
| @ -45,6 +47,8 @@ ImageCLI.prototype.init = function init(opts, args, cb) { | ||||
| 
 | ||||
| ImageCLI.prototype.do_list = require('./do_list'); | ||||
| ImageCLI.prototype.do_get = require('./do_get'); | ||||
| ImageCLI.prototype.do_create = require('./do_create'); | ||||
| ImageCLI.prototype.do_wait = require('./do_wait'); | ||||
| 
 | ||||
| 
 | ||||
| ImageCLI.aliases = ['img']; | ||||
|  | ||||
| @ -12,287 +12,15 @@ | ||||
| 
 | ||||
| var assert = require('assert-plus'); | ||||
| var format = require('util').format; | ||||
| var fs = require('fs'); | ||||
| var strsplit = require('strsplit'); | ||||
| var tabula = require('tabula'); | ||||
| var tilde = require('tilde-expansion'); | ||||
| var vasync = require('vasync'); | ||||
| 
 | ||||
| var common = require('../common'); | ||||
| var distractions = require('../distractions'); | ||||
| var errors = require('../errors'); | ||||
| var mat = require('../metadataandtags'); | ||||
| 
 | ||||
| 
 | ||||
| // ---- loading/parsing metadata (and tags) from relevant options
 | ||||
| 
 | ||||
| /* | ||||
|  * Load and validate metadata from these options: | ||||
|  *      -m,--metadata DATA | ||||
|  *      -M,--metadata-file KEY=FILE | ||||
|  *      --script FILE | ||||
|  * | ||||
|  * <https://github.com/joyent/sdc-vmapi/blob/master/docs/index.md#vm-metadata>
 | ||||
|  * says values may be string, num or bool. | ||||
|  */ | ||||
| function metadataFromOpts(opts, log, cb) { | ||||
|     assert.arrayOfObject(opts._order, 'opts._order'); | ||||
|     assert.object(log, 'log'); | ||||
|     assert.func(cb, 'cb'); | ||||
| 
 | ||||
|     var metadata = {}; | ||||
| 
 | ||||
|     vasync.forEachPipeline({ | ||||
|         inputs: opts._order, | ||||
|         func: function metadataFromOpt(o, next) { | ||||
|             log.trace({opt: o}, 'metadataFromOpt'); | ||||
|             if (o.key === 'metadata') { | ||||
|                 if (!o.value) { | ||||
|                     next(new errors.UsageError( | ||||
|                         'empty metadata option value')); | ||||
|                     return; | ||||
|                 } else if (o.value[0] === '{') { | ||||
|                     _addMetadataFromJsonStr( | ||||
|                         'metadata', metadata, o.value, null, next); | ||||
|                 } else if (o.value[0] === '@') { | ||||
|                     _addMetadataFromFile( | ||||
|                         'metadata', metadata, o.value.slice(1), next); | ||||
|                 } else { | ||||
|                     _addMetadataFromKvStr( | ||||
|                         'metadata', metadata, o.value, null, next); | ||||
|                 } | ||||
|             } else if (o.key === 'metadata_file') { | ||||
|                 _addMetadataFromKfStr( | ||||
|                     'metadata', metadata, o.value, null, next); | ||||
|             } else if (o.key === 'script') { | ||||
|                 _addMetadatumFromFile('metadata', metadata, | ||||
|                     'user-script', o.value, o.value, next); | ||||
|             } else { | ||||
|                 next(); | ||||
|             } | ||||
|         } | ||||
|     }, function (err) { | ||||
|         if (err) { | ||||
|             cb(err); | ||||
|         } else if (Object.keys(metadata).length) { | ||||
|             cb(null, metadata); | ||||
|         } else { | ||||
|             cb(); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /* | ||||
|  * Load and validate tags from these options: | ||||
|  *      -t,--tag DATA | ||||
|  * | ||||
|  * <https://github.com/joyent/sdc-vmapi/blob/master/docs/index.md#vm-metadata>
 | ||||
|  * says values may be string, num or bool. | ||||
|  */ | ||||
| function tagsFromOpts(opts, log, cb) { | ||||
|     assert.arrayOfObject(opts._order, 'opts._order'); | ||||
|     assert.object(log, 'log'); | ||||
|     assert.func(cb, 'cb'); | ||||
| 
 | ||||
|     var tags = {}; | ||||
| 
 | ||||
|     vasync.forEachPipeline({ | ||||
|         inputs: opts._order, | ||||
|         func: function tagsFromOpt(o, next) { | ||||
|             log.trace({opt: o}, 'tagsFromOpt'); | ||||
|             if (o.key === 'tag') { | ||||
|                 if (!o.value) { | ||||
|                     next(new errors.UsageError( | ||||
|                         'empty tag option value')); | ||||
|                     return; | ||||
|                 } else if (o.value[0] === '{') { | ||||
|                     _addMetadataFromJsonStr('tag', tags, o.value, null, next); | ||||
|                 } else if (o.value[0] === '@') { | ||||
|                     _addMetadataFromFile('tag', tags, o.value.slice(1), next); | ||||
|                 } else { | ||||
|                     _addMetadataFromKvStr('tag', tags, o.value, null, next); | ||||
|                 } | ||||
|             } else { | ||||
|                 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'); | ||||
|     assert.object(metadata, 'metadata'); | ||||
|     assert.string(key, 'key'); | ||||
|     assert.optionalString(from, 'from'); | ||||
|     assert.func(cb, 'cb'); | ||||
| 
 | ||||
|     if (allowedTypes.indexOf(typeof (value)) === -1) { | ||||
|         cb(new errors.UsageError(format( | ||||
|             'invalid %s value type%s: must be one of %s: %s=%j', | ||||
|             ilk, (from ? ' (from ' + from + ')' : ''), | ||||
|             allowedTypes.join(', '), key, value))); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     if (metadata.hasOwnProperty(key)) { | ||||
|         var valueStr = value.toString(); | ||||
|         console.error( | ||||
|             'warning: %s "%s=%s"%s replaces earlier value for "%s"', | ||||
|             ilk, | ||||
|             key, | ||||
|             (valueStr.length > 10 | ||||
|                 ? valueStr.slice(0, 7) + '...' : valueStr), | ||||
|             (from ? ' (from ' + from + ')' : ''), | ||||
|             key); | ||||
|     } | ||||
|     metadata[key] = value; | ||||
|     cb(); | ||||
| } | ||||
| 
 | ||||
| function _addMetadataFromObj(ilk, metadata, obj, from, cb) { | ||||
|     assert.string(ilk, 'ilk'); | ||||
|     assert.object(metadata, 'metadata'); | ||||
|     assert.object(obj, 'obj'); | ||||
|     assert.optionalString(from, 'from'); | ||||
|     assert.func(cb, 'cb'); | ||||
| 
 | ||||
|     vasync.forEachPipeline({ | ||||
|         inputs: Object.keys(obj), | ||||
|         func: function _oneField(key, next) { | ||||
|             _addMetadatum(ilk, metadata, key, obj[key], from, next); | ||||
|         } | ||||
|     }, cb); | ||||
| } | ||||
| 
 | ||||
| function _addMetadataFromJsonStr(ilk, metadata, s, from, cb) { | ||||
|     assert.string(ilk, 'ilk'); | ||||
|     try { | ||||
|         var obj = JSON.parse(s); | ||||
|     } catch (parseErr) { | ||||
|         cb(new errors.TritonError(parseErr, | ||||
|             format('%s%s is not valid JSON', ilk, | ||||
|                 (from ? ' (from ' + from + ')' : '')))); | ||||
|         return; | ||||
|     } | ||||
|     _addMetadataFromObj(ilk, metadata, obj, from, cb); | ||||
| } | ||||
| 
 | ||||
| function _addMetadataFromFile(ilk, metadata, file, cb) { | ||||
|     assert.string(ilk, 'ilk'); | ||||
|     tilde(file, function (metaPath) { | ||||
|         fs.stat(metaPath, function (statErr, stats) { | ||||
|             if (statErr || !stats.isFile()) { | ||||
|                 cb(new errors.TritonError(format( | ||||
|                     '"%s" is not an existing file', file))); | ||||
|                 return; | ||||
|             } | ||||
|             fs.readFile(metaPath, 'utf8', function (readErr, data) { | ||||
|                 if (readErr) { | ||||
|                     cb(readErr); | ||||
|                     return; | ||||
|                 } | ||||
|                 /* | ||||
|                  * The file is either a JSON object (first non-space | ||||
|                  * char is '{'), or newline-separated key=value | ||||
|                  * pairs. | ||||
|                  */ | ||||
|                 var dataTrim = data.trim(); | ||||
|                 if (dataTrim.length && dataTrim[0] === '{') { | ||||
|                     _addMetadataFromJsonStr(ilk, metadata, dataTrim, file, cb); | ||||
|                 } else { | ||||
|                     var lines = dataTrim.split(/\r?\n/g).filter( | ||||
|                         function (line) { return line.trim(); }); | ||||
|                     vasync.forEachPipeline({ | ||||
|                         inputs: lines, | ||||
|                         func: function oneLine(line, next) { | ||||
|                             _addMetadataFromKvStr( | ||||
|                                 ilk, metadata, line, file, next); | ||||
|                         } | ||||
|                     }, cb); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| function _addMetadataFromKvStr(ilk, metadata, s, from, cb) { | ||||
|     assert.string(ilk, 'ilk'); | ||||
| 
 | ||||
|     var parts = strsplit(s, '=', 2); | ||||
|     if (parts.length !== 2) { | ||||
|         cb(new errors.UsageError(format( | ||||
|             'invalid KEY=VALUE %s argument: %s', ilk, s))); | ||||
|         return; | ||||
|     } | ||||
|     var value = parts[1]; | ||||
|     var valueTrim = value.trim(); | ||||
|     if (valueTrim === 'true') { | ||||
|         value = true; | ||||
|     } else if (valueTrim === 'false') { | ||||
|         value = false; | ||||
|     } else { | ||||
|         var num = Number(value); | ||||
|         if (!isNaN(num)) { | ||||
|             value = num; | ||||
|         } | ||||
|     } | ||||
|     _addMetadatum(ilk, metadata, parts[0].trim(), value, from, cb); | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|  * Add metadata from `KEY=FILE` argument. | ||||
|  * Here "Kf" stands for "key/file". | ||||
|  */ | ||||
| function _addMetadataFromKfStr(ilk, metadata, s, from, cb) { | ||||
|     assert.string(ilk, 'ilk'); | ||||
| 
 | ||||
|     var parts = strsplit(s, '=', 2); | ||||
|     if (parts.length !== 2) { | ||||
|         cb(new errors.UsageError(format( | ||||
|             'invalid KEY=FILE %s argument: %s', ilk, s))); | ||||
|         return; | ||||
|     } | ||||
|     var key = parts[0].trim(); | ||||
|     var file = parts[1]; | ||||
| 
 | ||||
|     _addMetadatumFromFile(ilk, metadata, key, file, file, cb); | ||||
| } | ||||
| 
 | ||||
| function _addMetadatumFromFile(ilk, metadata, key, file, from, cb) { | ||||
|     assert.string(ilk, 'ilk'); | ||||
| 
 | ||||
|     tilde(file, function (filePath) { | ||||
|         fs.stat(filePath, function (statErr, stats) { | ||||
|             if (statErr || !stats.isFile()) { | ||||
|                 cb(new errors.TritonError(format( | ||||
|                     '%s path "%s" is not an existing file', ilk, file))); | ||||
|                 return; | ||||
|             } | ||||
|             fs.readFile(filePath, 'utf8', function (readErr, content) { | ||||
|                 if (readErr) { | ||||
|                     cb(readErr); | ||||
|                     return; | ||||
|                 } | ||||
|                 _addMetadatum(ilk, metadata, key, content, from, cb); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| // ---- the command
 | ||||
| 
 | ||||
| function do_create(subcmd, opts, args, cb) { | ||||
|     var self = this; | ||||
|     if (opts.help) { | ||||
| @ -307,7 +35,7 @@ function do_create(subcmd, opts, args, cb) { | ||||
| 
 | ||||
|     vasync.pipeline({arg: {}, funcs: [ | ||||
|         function loadMetadata(ctx, next) { | ||||
|             metadataFromOpts(opts, log, function (err, metadata) { | ||||
|             mat.metadataFromOpts(opts, log, function (err, metadata) { | ||||
|                 if (err) { | ||||
|                     next(err); | ||||
|                     return; | ||||
| @ -321,7 +49,7 @@ function do_create(subcmd, opts, args, cb) { | ||||
|             }); | ||||
|         }, | ||||
|         function loadTags(ctx, next) { | ||||
|             tagsFromOpts(opts, log, function (err, tags) { | ||||
|             mat.tagsFromOpts(opts, log, function (err, tags) { | ||||
|                 if (err) { | ||||
|                     next(err); | ||||
|                     return; | ||||
| @ -604,5 +332,3 @@ do_create.helpOpts = { | ||||
| 
 | ||||
| 
 | ||||
| module.exports = do_create; | ||||
| do_create.metadataFromOpts = metadataFromOpts; // export for testing
 | ||||
| do_create.tagsFromOpts = tagsFromOpts; // export for testing
 | ||||
|  | ||||
| @ -354,9 +354,6 @@ TritonApi.prototype.getImage = function getImage(opts, cb) { | ||||
|         ]}, function done(err) { | ||||
|             if (err) { | ||||
|                 cb(err); | ||||
|             } else if (img.state !== 'active') { | ||||
|                 cb(new errors.TritonError( | ||||
|                     format('image %s is not active', opts.name))); | ||||
|             } else { | ||||
|                 cb(null, img); | ||||
|             } | ||||
| @ -366,7 +363,10 @@ TritonApi.prototype.getImage = function getImage(opts, cb) { | ||||
|         var name = s[0]; | ||||
|         var version = s[1]; | ||||
| 
 | ||||
|         var listOpts = {}; | ||||
|         var listOpts = { | ||||
|             // Explicitly include inactive images.
 | ||||
|             state: 'all' | ||||
|         }; | ||||
|         if (version) { | ||||
|             listOpts.name = name; | ||||
|             listOpts.version = version; | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| { | ||||
|   "name": "triton", | ||||
|   "description": "Joyent Triton CLI and client (https://www.joyent.com/triton)", | ||||
|   "version": "4.2.1", | ||||
|   "version": "4.3.0", | ||||
|   "author": "Joyent (joyent.com)", | ||||
|   "dependencies": { | ||||
|     "assert-plus": "0.2.0", | ||||
|  | ||||
| @ -225,8 +225,10 @@ test('triton manage workflow', opts, function (tt) { | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     // remove instance
 | ||||
|     tt.test('  triton delete', function (t) { | ||||
|     // Remove instance. Add a test 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('  triton delete', {timeout: 10 * 60 * 1000}, function (t) { | ||||
|         h.safeTriton(t, ['delete', '-w', instance.id], function (stdout) { | ||||
|             t.end(); | ||||
|         }); | ||||
|  | ||||
| @ -17,8 +17,7 @@ var cmdln = require('cmdln'); | ||||
| var format = require('util').format; | ||||
| var test = require('tape'); | ||||
| 
 | ||||
| var metadataFromOpts = | ||||
|     require('../../lib/do_instance/do_create').metadataFromOpts; | ||||
| var metadataFromOpts = require('../../lib/metadataandtags').metadataFromOpts; | ||||
| 
 | ||||
| 
 | ||||
| // ---- globals
 | ||||
|  | ||||
| @ -17,7 +17,7 @@ var cmdln = require('cmdln'); | ||||
| var format = require('util').format; | ||||
| var test = require('tape'); | ||||
| 
 | ||||
| var tagsFromOpts = require('../../lib/do_instance/do_create').tagsFromOpts; | ||||
| var tagsFromOpts = require('../../lib/metadataandtags').tagsFromOpts; | ||||
| 
 | ||||
| 
 | ||||
| // ---- globals
 | ||||
|  | ||||
		Reference in New Issue
	
	Block a user