diff --git a/TODO.txt b/TODO.txt index 91d3d64..aced7af 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,9 +1,6 @@ # today -triton instances|insts # list machines -triton instance|inst ID|NAME|UNIQUE-NAME-SUBSTRING # get machine - # -1 for unique match, a la 'vmadm lookup -1' triton create # triton create-instance triton create -p PKG [...] IMG @@ -23,38 +20,22 @@ triton image IMAGE # get image triton packages # list packages triton package PACKAGE +triton instances|insts # list machines +triton instance|inst ID|NAME # get machine +triton cloudapi ... # maybe today -triton config defaultPkg t4-standard-1g - triton login|ssh VM # kexec? triton delete VM|IMAGE # substring matching? too dangerous triton delete --vm VM triton delete --image IMAGE -triton raw|cloudapi ... # raw cloudapi call - Equivalent of: - function cloudapi() { - local now=`date -u "+%a, %d %h %Y %H:%M:%S GMT"` ; - local signature=`echo ${now} | tr -d '\n' | openssl dgst -sha256 -sign ~/.ssh/automation.id_rsa | openssl enc -e -a | tr -d '\n'` ; - local curl_opts= - [[ -n $SDC_TESTING ]] && curl_opts="-k $curl_opts"; - [[ -n $TRACE ]] && set -x; - curl -is $curl_opts \ - -H "Accept: application/json" -H "api-version: ~7.2" \ - -H "Date: ${now}" \ - -H "Authorization: Signature keyId=\"/$SDC_ACCOUNT/keys/$SDC_KEY_ID\",algorithm=\"rsa-sha256\" ${signature}" \ - --url $SDC_URL$@ ; - [[ -n $TRACE ]] && set +x; - echo ""; - } - "shortid" instead of full UUID "id" in default output, and then allow lookup by that shortid. Really nice for 80 columns. diff --git a/lib/cli.js b/lib/cli.js index c0efae7..1df36b1 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -61,7 +61,7 @@ function CLI() { { group: 'Operator Commands' }, 'account', { group: 'Instances (aka VMs/Machines/Containers)' }, - 'create', + 'create-instance', 'instances', 'instance', 'instance-audit', @@ -117,9 +117,9 @@ CLI.prototype.do_images = require('./do_images'); CLI.prototype.do_image = require('./do_image'); // Instances (aka VMs/containers/machines) -CLI.prototype.do_create = require('./do_create'); CLI.prototype.do_instance = require('./do_instance'); CLI.prototype.do_instances = require('./do_instances'); +CLI.prototype.do_create_instance = require('./do_create_instance'); CLI.prototype.do_instance_audit = require('./do_instance_audit'); CLI.prototype.do_stop_instance = require('./do_startstop_instance')('stop'); CLI.prototype.do_start_instance = require('./do_startstop_instance')('start'); diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index b63707c..561a8ca 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -404,19 +404,19 @@ CloudAPI.prototype._doMachine = function _doMachine(action, uuid, callback) { }; /** - * wait for a specfic state for a machine + * Wait for a machine to go one of a set of specfic states. * * @param {Object} options * - {String} id - machine UUID - * - {String} state - desired state + * - {Array of String} states - desired state * - {Number} interval (optional) - time in ms to poll * @param {Function} callback - called when state is reached or on error */ -CloudAPI.prototype.waitForMachineState = function waitForMachineState(opts, callback) { +CloudAPI.prototype.waitForMachineStates = function waitForMachineStates(opts, callback) { var self = this; assert.object(opts, 'opts'); assert.string(opts.id, 'opts.id'); - assert.string(opts.state, 'opts.state'); + assert.arrayOfString(opts.states, 'opts.states'); assert.optionalNumber(opts.interval, 'opts.interval'); assert.func(callback, 'callback'); var interval = (opts.interval === undefined ? 1000 : opts.interval); @@ -429,7 +429,7 @@ CloudAPI.prototype.waitForMachineState = function waitForMachineState(opts, call callback(err); return; } - if (machine.state === opts.state) { + if (opts.states.indexOf(machine.state) !== -1) { callback(null, machine); return; } @@ -497,6 +497,26 @@ CloudAPI.prototype.listMachines = function listMachines(options, callback) { }); }; + +CloudAPI.prototype.createMachine = function createMachine(options, callback) { + assert.object(options, 'options'); + assert.optionalString(options.name, 'options.name'); + assert.uuid(options.image, 'options.image'); + assert.uuid(options.package, 'options.package'); + assert.optionalArrayOfUuid(options.networks, 'options.networks'); + // TODO: assert the other fields + assert.func(callback, 'callback'); + + // XXX how does options.networks array work here? + this._request({ + method: 'POST', + path: this._path(format('/%s/machines', this.user), options) + }, function (err, req, res, body) { + callback(err, body, res); + }); +}; + + /** * List machine audit (successful actions on the machine). * diff --git a/lib/common.js b/lib/common.js index 7763507..362f4af 100755 --- a/lib/common.js +++ b/lib/common.js @@ -1,11 +1,8 @@ -#!/usr/bin/env node /** * Copyright (c) 2015 Joyent Inc. All rights reserved. */ - var assert = require('assert-plus'); -var sprintf = require('extsprintf').sprintf; var util = require('util'), format = util.format; @@ -17,6 +14,7 @@ var errors = require('./errors'), var p = console.log; +var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; // ---- support stuff @@ -142,9 +140,45 @@ function isUUID(s) { return /^([a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}?)$/i.test(s); } + +function humanDurationFromMs(ms) { + assert.number(ms, 'ms'); + var sizes = [ + ['ms', 1000, 's'], + ['s', 60, 'm'], + ['m', 60, 'h'], + ['h', 24, 'd'] + ]; + if (ms === 0) { + return '0ms'; + } + var bits = []; + var n = ms; + for (var i = 0; i < sizes.length; i++) { + var size = sizes[i]; + var remainder = n % size[1]; + if (remainder === 0) { + bits.unshift(''); + } else { + bits.unshift(format('%d%s', remainder, size[0])); + } + n = Math.floor(n / size[1]); + if (n === 0) { + break; + } else if (size[2] === 'd') { + bits.unshift(format('%d%s', n, size[2])); + break; + } + } + return bits.slice(0, 2).join(''); +} + + + //---- exports module.exports = { + UUID_RE: UUID_RE, objCopy: objCopy, deepObjCopy: deepObjCopy, zeroPad: zeroPad, @@ -153,5 +187,6 @@ module.exports = { kvToObj: kvToObj, longAgo: longAgo, isUUID: isUUID, + humanDurationFromMs: humanDurationFromMs }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_create_instance.js b/lib/do_create_instance.js new file mode 100644 index 0000000..652e391 --- /dev/null +++ b/lib/do_create_instance.js @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2015 Joyent Inc. All rights reserved. + * + * `triton create ...` + */ + +var bigspinner = require('bigspinner'); +var format = require('util').format; +var tabula = require('tabula'); +var vasync = require('vasync'); + +var common = require('./common'); +var errors = require('./errors'); + + +function do_create_instance(subcmd, opts, args, callback) { + var self = this; + if (opts.help) { + this.do_help('help', {}, [subcmd], callback); + return; + } else if (args.length < 1 || args.length > 2) { + return callback(new errors.UsageError(format( + 'incorrect number of args (%d): %s', args.length, args.join(' ')))); + } + + var log = this.triton.log; + var cloudapi = this.triton.cloudapi; + var cOpts = {}; + + vasync.pipeline({arg: {}, funcs: [ + function getImg(ctx, next) { + // XXX don't get the image object if it is a UUID, waste of time + self.triton.getImage(args[0], function (err, img) { + if (err) { + return next(err); + } + ctx.img = img; + log.trace({img: img}, 'create-instance img'); + next(); + }); + }, + function getPkg(ctx, next) { + if (args.length < 2) { + return next(); + } + // XXX don't get the package object if it is a UUID, waste of time + self.triton.getPackage(args[1], function (err, pkg) { + if (err) { + return next(err); + } + log.trace({pkg: pkg}, 'create-instance pkg'); + ctx.pkg = pkg; + next(); + }); + }, + function getNets(ctx, next) { + if (!opts.networks) { + return next(); + } + self.triton.getNetworks(opts.networks, function (err, nets) { + if (err) { + return next(err); + } + ctx.nets = nets; + next(); + }); + }, + function createInst(ctx, next) { + var createOpts = { + name: opts.name, + image: ctx.img.id, + 'package': ctx.pkg && ctx.pkg.id, + networks: ctx.nets && ctx.nets.map( + function (net) { return net.id; }) + }; + log.trace({createOpts: createOpts}, 'create-instance createOpts'); + ctx.start = Date.now(); + cloudapi.createMachine(createOpts, function (err, inst) { + if (err) { + return next(err); + } + ctx.inst = inst; + if (opts.json) { + console.log(JSON.stringify(inst)); + } else { + console.log('Creating instance %s (%s, %s@%s, %s)', + inst.name, inst.id, ctx.img.name, ctx.img.version, + inst.package); + } + next(); + }); + }, + function maybeWait(ctx, next) { + if (!opts.wait) { + return next(); + } + + var spinner; + if (!opts.quiet && process.stderr.isTTY) { + spinner = bigspinner.createSpinner({ + delay: 250, + stream: process.stderr, + height: process.stdout.rows - 2, + width: process.stdout.columns - 1, + hideCursor: true, + fontChar: '#' + }); + } + + cloudapi.waitForMachineStates({ + id: ctx.inst.id, + states: ['running', 'failed'] + }, function (err, inst) { + if (spinner) { + spinner.destroy(); + } + if (err) { + return next(err); + } + if (opts.json) { + console.log(JSON.stringify(inst)); + } else if (inst.state === 'running') { + var dur = Date.now() - ctx.start; + console.log('Created instance %s (%s) in %s', + inst.name, inst.id, common.humanDurationFromMs(dur)); + } + if (inst.state !== 'running') { + next(new Error(format('failed to create instance %s (%s)', + inst.name, inst.id))); + } else { + next(); + } + }); + } + ]}, function (err) { + callback(err); + }); +}; + +do_create_instance.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + group: 'Create options' + }, + { + names: ['name', 'n'], + type: 'string', + help: 'One or more (comma-separated) networks IDs.' + }, + // XXX arrayOfCommaSepString dashdash type + //{ + // names: ['networks', 'nets'], + // type: 'arrayOfCommaSepString', + // help: 'One or more (comma-separated) networks IDs.' + //}, + // XXX enable-firewall + // XXX locality: near, far + // XXX metadata, metadata-file + // XXX script (user-script) + // XXX tag + { + group: 'Other options' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for the creation to complete.' + }, + { + names: ['quiet', 'q'], + type: 'bool', + help: 'No progress spinner while waiting.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; +do_create_instance.help = ( + /* BEGIN JSSTYLED */ + 'Create a new instance.\n' + + '\n' + + 'Usage:\n' + + ' {{name}} create-instance [] IMAGE [PACKAGE]\n' + + '\n' + + '{{options}}' + /* END JSSTYLED */ +); + +do_create_instance.aliases = ['create']; + +module.exports = do_create_instance; diff --git a/lib/do_packages.js b/lib/do_packages.js index e9fa217..843c811 100644 --- a/lib/do_packages.js +++ b/lib/do_packages.js @@ -42,8 +42,7 @@ function do_packages (subcmd, opts, args, callback) { tabula(packages, { skipHeader: opts.H, columns: columns, - sort: sort, - validFields: 'name,memory,disk,swap,vcpus,lwps,default,id,version'.split(',') + sort: sort }); } callback(); @@ -64,7 +63,7 @@ do_packages.options = [ { names: ['o'], type: 'string', - default: 'id,name,version,memory,disk', + default: 'id,name,default,memory,disk', help: 'Specify fields (columns) to output.', helpArg: 'field1,...' }, diff --git a/lib/do_startstop_instance.js b/lib/do_startstop_instance.js index 9495e5e..8b333a6 100644 --- a/lib/do_startstop_instance.js +++ b/lib/do_startstop_instance.js @@ -98,11 +98,10 @@ function _do_instance(action, subcmd, opts, args, callback) { return; } - var waitOpts = { - state: state, - id: uuid - }; - self.triton.cloudapi.waitForMachineState(waitOpts, function (err, machine) { + self.triton.cloudapi.waitForMachineStates({ + id: uuid, + states: [state] + }, function (err, machine) { if (err) { callback(err); return; diff --git a/lib/triton.js b/lib/triton.js index 85fead8..1682486 100644 --- a/lib/triton.js +++ b/lib/triton.js @@ -13,6 +13,7 @@ var once = require('once'); var path = require('path'); var restifyClients = require('restify-clients'); var sprintf = require('util').format; +var tabula = require('tabula'); var cloudapi = require('./cloudapi2'); var common = require('./common'); @@ -105,130 +106,89 @@ Triton.prototype._cloudapiFromProfile = function _cloudapiFromProfile(profile) { /** - * Find a machine in the set of DCs for the current profile. - * - * - * @param {Object} options - * - {String} machine (required) The machine id. - * XXX support name matching, prefix, etc. - * @param {Function} callback `function (err, machine, dc)` - * Returns the machine object (as from cloudapi GetMachine) and the `dc`, - * e.g. "us-west-1". + * Get an image by ID or name. If there is more than one image with that name, + * then the latest (by published_at) is returned. */ -Triton.prototype.findMachine = function findMachine(options, callback) { - //XXX Eventually this can be cached for a *full* uuid. Arguably for a - // uuid prefix or machine alias match, it cannot be cached, because an - // ambiguous machine could have been added. - var self = this; - assert.object(options, 'options'); - assert.string(options.machine, 'options.machine'); - assert.func(callback, 'callback'); - var callback = once(callback); +Triton.prototype.getImage = function getImage(name, cb) { + assert.string(name, 'name'); + assert.func(cb, 'cb'); - var errs = []; - var foundMachine; - var foundDc; - async.each( - self.dcs(), - function oneDc(dc, next) { - var client = self._clientFromDc(dc.name); - client.getMachine({id: options.machine}, function (err, machine) { - if (err) { - errs.push(err); - } else if (machine) { - foundMachine = machine; - foundDc = dc.name; - // Return early on an unambiguous match. - // XXX When other than full 'id' is supported, this isn't unambiguous. - callback(null, foundMachine, foundDc); - } - next(); - }); - }, - function done(surpriseErr) { - if (surpriseErr) { - callback(surpriseErr); - } else if (foundMachine) { - callback(null, foundMachine, foundDc) - } else if (errs.length) { - callback(errs.length === 1 ? - errs[0] : new errors.MultiError(errs)); + if (common.UUID_RE.test(name)) { + this.cloudapi.getImage({id: name}, function (err, img) { + if (err) { + cb(err); + } else if (img.state !== 'active') { + cb(new Error(format('image %s is not active', name))); } else { - callback(new errors.InternalError( - 'unexpected error finding machine ' + options.id)); + cb(null, img); } - } - ); -}; - - -/** - * List machines for the current profile. - * - * var res = this.jc.listMachines(); - * res.on('data', function (dc, dcMachines) { - * //... - * }); - * res.on('dcError', function (dc, dcErr) { - * //... - * }); - * res.on('end', function () { - * //... - * }); - * - * @param {Object} options Optional - */ -Triton.prototype.listMachines = function listMachines(options) { - var self = this; - if (options === undefined) { - options = {}; - } - assert.object(options, 'options'); - - var emitter = new EventEmitter(); - async.each( - self.dcs(), - function oneDc(dc, next) { - var client = self._clientFromDc(dc.name); - client.listMachines(function (err, machines) { - if (err) { - emitter.emit('dcError', dc.name, err); - } else { - emitter.emit('data', dc.name, machines); - } - next(); - }); - }, - function done(err) { - emitter.emit('end'); - } - ); - return emitter; -}; - - -/** - * Return the audit for the given machine. - * - * @param {Object} options - * - {String} machine (required) The machine id. - * XXX support `machine` being more than just the UUID. - * @param {Function} callback of the form `function (err, audit, dc)` - */ -Triton.prototype.machineAudit = function machineAudit(options, callback) { - var self = this; - assert.object(options, 'options'); - assert.string(options.machine, 'options.machine'); - - self.findMachine({machine: options.machine}, function (err, machine, dc) { - if (err) { - return callback(err); - } - var client = self._clientFromDc(dc); - client.machineAudit({id: machine.id}, function (err, audit) { - callback(err, audit, dc); }); - }); + } else { + this.cloudapi.listImages(function (err, imgs) { + if (err) { + return cb(err); + } + var nameMatches = []; + for (var i = 0; i < imgs.length; i++) { + if (imgs[i].name === name) { + nameMatches.push(imgs[i]); + } + } + if (nameMatches.length === 0) { + cb(new Error(format('no image with name=%s was found', + name))); + } else if (nameMatches.length === 1) { + cb(null, nameMatches[0]); + } else { + tabula.sortArrayOfObjects(nameMatches, 'published_at'); + cb(null, nameMatches[nameMatches.length - 1]); + } + }); + } +}; + + +/** + * Get an active package by ID or name. If there is more than one package + * with that name, then this errors out. + */ +Triton.prototype.getPackage = function getPackage(name, cb) { + assert.string(name, 'name'); + assert.func(cb, 'cb'); + + if (common.UUID_RE.test(name)) { + this.cloudapi.getPackage({id: name}, function (err, pkg) { + if (err) { + cb(err); + } else if (!pkg.active) { + cb(new Error(format('image %s is not active', name))); + } else { + cb(null, pkg); + } + }); + } else { + this.cloudapi.listPackages(function (err, pkgs) { + if (err) { + return cb(err); + } + var nameMatches = []; + for (var i = 0; i < pkgs.length; i++) { + if (pkgs[i].name === name) { + nameMatches.push(pkgs[i]); + } + } + if (nameMatches.length === 0) { + cb(new Error(format('no package with name=%s was found', + name))); + } else if (nameMatches.length === 1) { + cb(null, nameMatches[0]); + } else { + cb(new Error(format( + 'package name "%s" is ambiguous: matches %d packages', + name, nameMatches.length))); + } + }); + } }; diff --git a/package.json b/package.json index 4120514..aaba95e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dependencies": { "assert-plus": "0.1.5", "backoff": "2.4.1", + "bigspinner": "^3.0.0", "bunyan": "1.4.0", "cmdln": "3.2.3", "dashdash": "1.10.0",