diff --git a/TODO.txt b/TODO.txt index 1699d69..c3cae5c 100644 --- a/TODO.txt +++ b/TODO.txt @@ -35,6 +35,29 @@ 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. + +triton images + Drop 'state' in default columns. Add type to be able to see lx or not + for 'linux' ones. That might hit that stupid naming problem. # profiles diff --git a/lib/cli.js b/lib/cli.js index 3a83b4a..b659650 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -97,6 +97,8 @@ CLI.prototype.do_foo = function do_foo(subcmd, opts, args, callback) { CLI.prototype.do_profile = require('./do_profile'); +CLI.prototype.do_images = require('./do_images'); + CLI.prototype.do_provision = function (subcmd, opts, args, callback) { diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 79800be..009100e 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -28,6 +28,7 @@ var p = console.log; var assert = require('assert-plus'); var auth = require('smartdc-auth'); +var format = require('util').format; var os = require('os'); var querystring = require('querystring'); var restifyClients = require('restify-clients'); @@ -147,6 +148,61 @@ CloudAPI.prototype._getAuthHeaders = function _getAuthHeaders(callback) { }); }; +/** + * Return an appropriate query string *with the leading '?'* from the given + * fields. If any of the field values are undefined or null, then they will + * be excluded. + */ +CloudAPI.prototype._qs = function _qs(fields, fields2) { + assert.object(fields, 'fields'); + assert.optionalObject(fields2, 'fields2'); // can be handy to pass in 2 objs + + var query = {}; + Object.keys(fields).forEach(function (key) { + var value = fields[key]; + if (value !== undefined && value !== null) { + query[key] = value; + } + }); + if (fields2) { + Object.keys(fields2).forEach(function (key) { + var value = fields2[key]; + if (value !== undefined && value !== null) { + query[key] = value; + } + }); + } + + if (Object.keys(query).length === 0) { + return ''; + } else { + return '?' + querystring.stringify(query); + } +}; + + +/** + * Return an appropriate full URL *path* given an CloudAPI subpath. + * This handles prepending the API's base path, if any: e.g. if the configured + * URL is "https://example.com/base/path". + * + * Optionally an object of query params can be passed in to include a query + * string. This just calls `this._qs(...)`. + */ +CloudAPI.prototype._path = function _path(subpath, qparams, qparams2) { + assert.string(subpath, 'subpath'); + assert.ok(subpath[0] === '/'); + assert.optionalObject(qparams, 'qparams'); + assert.optionalObject(qparams2, 'qparams2'); // can be handy to pass in 2 + + var path = subpath; + if (qparams) { + path += this._qs(qparams, qparams2); + } + return path; +}; + + // ---- accounts @@ -157,7 +213,7 @@ CloudAPI.prototype._getAuthHeaders = function _getAuthHeaders(callback) { * @param {Object} options (optional) * @param {Function} callback of the form `function (err, user)` */ -CloudAPI.prototype.getAccount = function (options, callback) { +CloudAPI.prototype.getAccount = function getAccount(options, callback) { var self = this; if (callback === undefined) { callback = options; @@ -187,6 +243,54 @@ CloudAPI.prototype.getAccount = function (options, callback) { }; +// ---- images + +/** + * + * + * @param {Object} options (optional) + * XXX document this, see the api doc above :) + * @param {Function} callback of the form `function (err, images, res)` + */ +CloudAPI.prototype.listImages = function listImages(options, callback) { + var self = this; + if (callback === undefined) { + callback = options; + options = {}; + } + assert.object(options, 'options'); + assert.func(callback, 'callback'); + + var query = { + name: options.name, + os: options.os, + version: options.version, + public: options.public, + state: options.state, + owner: options.owner, + type: options.type + }; + + self._getAuthHeaders(function (hErr, headers) { + if (hErr) { + callback(hErr); + return; + } + var opts = { + path: self._path(format('/%s/images', self.user), query), + headers: headers + }; + self.client.get(opts, function (err, req, res, body) { + if (err) { + callback(err, null, res); + } else { + callback(null, body, res); + } + }); + }); +}; + + // ---- machines /** diff --git a/lib/do_images.js b/lib/do_images.js new file mode 100644 index 0000000..cf0b8d6 --- /dev/null +++ b/lib/do_images.js @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2015 Joyent Inc. All rights reserved. + * + * `triton images ...` + */ + +var format = require('util').format; +var tabula = require('tabula'); + +var errors = require('./errors'); + + +function do_images(subcmd, opts, args, callback) { + if (opts.help) { + this.do_help('help', {}, [subcmd], callback); + return; + } + + /* JSSTYLED */ + var columns = opts.o.trim().split(/\s*,\s*/g); + /* JSSTYLED */ + var sort = opts.s.trim().split(/\s*,\s*/g); + + var listOpts = {}; + var validFilters = [ + 'name', 'os', 'version', 'public', 'state', 'owner', 'type' + ]; + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + var idx = arg.indexOf('='); + if (idx === -1) { + return callback(new errors.UsageError(format( + 'invalid filter: "%s" (must be of the form "field=value")', + arg))); + } + var k = arg.slice(0, idx); + var v = arg.slice(idx + 1); + if (validFilters.indexOf(k) === -1) { + return callback(new errors.UsageError(format( + 'invalid filter name: "%s" (must be one of "%s")', + k, validFilters.join('", "')))); + } + listOpts[k] = v; + } + if (opts.all) { + listOpts.state = 'all'; + } + + this.triton.cloudapi.listImages(listOpts, function onRes(err, imgs, res) { + if (err) { + return callback(err); + } + + if (opts.json) { + // XXX we should have a common method for all these: + // XXX json stream + // XXX sorting + // XXX if opts.o is given, then filter to just those fields? + for (var i = 0; i < imgs.length; i++) { + console.log(JSON.stringify(imgs[i])); + } + } else { + // Add some convenience fields + // Added fields taken from imgapi-cli.git. + for (var i = 0; i < imgs.length; i++) { + var img = imgs[i]; + img.shortid = img.id.split('-', 1)[0]; + if (img.published_at) { + // Just the date. + img.pubdate = img.published_at.slice(0, 10); + // Normalize on no milliseconds. + img.pub = img.published_at.replace(/\.\d+Z$/, 'Z'); + } + if (img.files && img.files[0]) { + img.size = img.files[0].size; + } + var flags = []; + if (img.origin) flags.push('I'); + if (img['public']) flags.push('P'); + if (img.state !== 'active') flags.push('X'); + img.flags = flags.length ? flags.join('') : undefined; + } + + tabula(imgs, { + skipHeader: opts.H, + columns: columns, + sort: sort + }); + } + callback(); + }); +}; + +do_images.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + group: 'Filtering options' + }, + { + names: ['all', 'a'], + type: 'bool', + help: 'List all images, not just "active" ones. This ' + + 'is a shortcut for the "state=all" filter.' + }, + { + group: 'Output options' + }, + { + names: ['H'], + type: 'bool', + help: 'Omit table header row.' + }, + { + names: ['o'], + type: 'string', + default: 'id,name,version,state,flags,os,pubdate', + help: 'Specify fields (columns) to output.', + helpArg: 'field1,...' + }, + { + names: ['s'], + type: 'string', + default: 'published_at', + help: 'Sort on the given fields. Default is "published_at".', + helpArg: 'field1,...' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; +do_images.help = ( + 'List images.\n' + + '\n' + + 'Usage:\n' + + ' {{name}} images\n' + + '\n' + + '{{options}}' +); +do_images.help = ( + /* BEGIN JSSTYLED */ + 'List images.\n' + + '\n' + + 'Usage:\n' + + ' {{name}} list [] []\n' + + '\n' + + 'Filters:\n' + + ' FIELD=VALUE Field equality filter. Supported fields: \n' + + ' account, owner, state, name, os, and type.\n' + + ' FIELD=true|false Field boolean filter. Supported fields: public.\n' + + ' FIELD=~SUBSTRING Field substring filter. Supported fields: name\n' + + '\n' + + 'Fields (most are self explanatory, the client adds some for convenience):\n' + + ' flags This is a set of single letter flags\n' + + ' summarizing some fields. "P" indicates the\n' + + ' image is public. "I" indicates an incremental\n' + + ' image (i.e. has an origin). "X" indicates an\n' + + ' image with a state *other* than "active".\n' + + ' pubdate Short form of "published_at" with just the date\n' + + ' pub Short form of "published_at" elliding milliseconds.\n' + + ' size The number of bytes of the image file (files.0.size)\n' + + '\n' + + '{{options}}' + /* END JSSTYLED */ +); + +module.exports = do_images; diff --git a/lib/errors.js b/lib/errors.js index 56a2c30..927842d 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -19,7 +19,7 @@ var verror = require('verror'), * Base error. Instances will always have a string `message` and * a string `code` (a CamelCase string). */ -function SDCError(options) { +function TritonError(options) { assert.object(options, 'options'); assert.string(options.message, 'options.message'); assert.string(options.code, 'options.code'); @@ -38,7 +38,7 @@ function SDCError(options) { self[k] = options[k]; }); } -util.inherits(SDCError, VError); +util.inherits(TritonError, VError); function InternalError(cause, message) { if (message === undefined) { @@ -46,14 +46,14 @@ function InternalError(cause, message) { cause = undefined; } assert.string(message); - SDCError.call(this, { + TritonError.call(this, { cause: cause, message: message, code: 'InternalError', exitStatus: 1 }); } -util.inherits(InternalError, SDCError); +util.inherits(InternalError, TritonError); /** @@ -65,14 +65,14 @@ function ConfigError(cause, message) { cause = undefined; } assert.string(message); - SDCError.call(this, { + TritonError.call(this, { cause: cause, message: message, code: 'Config', exitStatus: 1 }); } -util.inherits(ConfigError, SDCError); +util.inherits(ConfigError, TritonError); /** @@ -84,28 +84,28 @@ function UsageError(cause, message) { cause = undefined; } assert.string(message); - SDCError.call(this, { + TritonError.call(this, { cause: cause, message: message, code: 'Usage', exitStatus: 1 }); } -util.inherits(UsageError, SDCError); +util.inherits(UsageError, TritonError); /** * An error signing a request. */ function SigningError(cause) { - SDCError.call(this, { + TritonError.call(this, { cause: cause, message: 'error signing request', code: 'Signing', exitStatus: 1 }); } -util.inherits(SigningError, SDCError); +util.inherits(SigningError, TritonError); /** @@ -118,7 +118,7 @@ function MultiError(errs) { var err = errs[i]; lines.push(format(' error (%s): %s', err.code, err.message)); } - SDCError.call(this, { + TritonError.call(this, { cause: errs[0], message: lines.join('\n'), code: 'MultiError', @@ -126,14 +126,14 @@ function MultiError(errs) { }); } MultiError.description = 'Multiple errors.'; -util.inherits(MultiError, SDCError); +util.inherits(MultiError, TritonError); // ---- exports module.exports = { - SDCError: SDCError, + TritonError: TritonError, InternalError: InternalError, ConfigError: ConfigError, UsageError: UsageError, diff --git a/lib/triton.js b/lib/triton.js index 81eec2d..02be813 100644 --- a/lib/triton.js +++ b/lib/triton.js @@ -42,7 +42,6 @@ function Triton(options) { if (options.log.serializers && (!options.log.serializers.client_req || !options.log.serializers.client_req)) { - console.log('XXX here'); this.log = options.log.child({ // XXX cheating. restify-clients should export its 'bunyan'. serializers: require('restify-clients/lib/helpers/bunyan').serializers diff --git a/package.json b/package.json index 1fe95f1..80f9374 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "once": "1.3.2", "restify-clients": "1.0.0", "smartdc-auth": "git+ssh://git@github.com:joyent/node-smartdc-auth.git#9f21966", - "vasync": "*", + "tabula": "1.4.2", + "vasync": "1.6.3", "verror": "1.6.0" }, "engines": {