diff --git a/CHANGES.md b/CHANGES.md index 00a091d..d99c58f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,17 @@ # node-triton changelog -## 3.3.1 (not yet released) +## 3.4.0 (not yet released) -(nothing yet) +- Improvements for using node-triton as a module. E.g. a simple example: + + var triton = require('triton'); + var client = triton.createClient({profileName: 'env'}); + client.listImages(function (err, imgs) { + console.log(err); + console.log(imgs); + }); + + See the README and "lib/index.js" for more info. ## 3.3.0 diff --git a/README.md b/README.md index 5df5958..658dbd9 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ For a more permanent installation: triton completion > /usr/local/etc/bash_completion.d/triton -## Examples +## `triton` CLI Usage ### Create and view instances @@ -214,6 +214,33 @@ as a single `DOCKER_HOST`. See the [Triton Docker documentation](https://apidocs.joyent.com/docker) for more information.) +## `TritonApi` Module Usage + +Node-triton can also be used as a node module for your own node.js tooling. +A basic example: + + var triton = require('triton'); + + // See `createClient` block comment for full usage details: + // https://github.com/joyent/node-triton/blob/master/lib/index.js + var client = triton.createClient({ + profile: { + url: URL, + account: ACCOUNT, + keyId: KEY_ID + } + }); + client.listImages(function (err, images) { + client.close(); // Remember to close the client to close TCP conn. + if (err) { + console.error('listImages err:', err); + } else { + console.log(JSON.stringify(images, null, 4)); + } + }); + + + ## Configuration This section defines all the vars in a TritonApi config. The baked in defaults @@ -229,7 +256,8 @@ are in "etc/defaults.json" and can be overriden for the CLI in ## node-triton differences with node-smartdc - There is a single `triton` command instead of a number of `sdc-*` commands. -- The `SDC_USER` env variable is accepted in preference to `SDC_ACCOUNT`. +- `TRITON_*` environment variables are preferred to the `SDC_*` environment + variables. However the `SDC_*` envvars are still supported. ## cloudapi2.js differences with node-smartdc/lib/cloudapi.js diff --git a/lib/bunyannoop.js b/lib/bunyannoop.js new file mode 100644 index 0000000..3f1e37d --- /dev/null +++ b/lib/bunyannoop.js @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/* + * A stub for a `bunyan.createLogger()` that does no logging. + */ +function BunyanNoopLogger() {} +BunyanNoopLogger.prototype.trace = function () {}; +BunyanNoopLogger.prototype.debug = function () {}; +BunyanNoopLogger.prototype.info = function () {}; +BunyanNoopLogger.prototype.warn = function () {}; +BunyanNoopLogger.prototype.error = function () {}; +BunyanNoopLogger.prototype.fatal = function () {}; +BunyanNoopLogger.prototype.child = function () { return this; }; +BunyanNoopLogger.prototype.end = function () {}; + + +module.exports = { + BunyanNoopLogger: BunyanNoopLogger +}; diff --git a/lib/cli.js b/lib/cli.js index 78aad82..fc9d6c7 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -212,12 +212,6 @@ CLI.prototype.init = function (opts, args, callback) { var self = this; this.opts = opts; - if (opts.version) { - console.log(this.name, pkg.version); - callback(false); - return; - } - this.log = bunyan.createLogger({ name: this.name, serializers: bunyan.stdSerializers, @@ -230,6 +224,12 @@ CLI.prototype.init = function (opts, args, callback) { this.showErrStack = true; } + if (opts.version) { + console.log(this.name, pkg.version); + callback(false); + return; + } + if (opts.url && opts.J) { callback(new errors.UsageError( 'cannot use both "--url" and "-J" options')); @@ -268,6 +268,16 @@ CLI.prototype.init = function (opts, args, callback) { }; +CLI.prototype.fini = function fini(subcmd, err, cb) { + this.log.trace({err: err, subcmd: subcmd}, 'cli fini'); + if (this._tritonapi) { + this._tritonapi.close(); + delete this._tritonapi; + } + cb(err, subcmd); +}; + + /* * Apply overrides from CLI options to the given profile object *in place*. */ @@ -380,7 +390,14 @@ function main(argv) { } } - process.exit(exitStatus); + /* + * We'd like to NOT use `process.exit` because that doesn't always + * allow std handles to flush (e.g. all logging to complete). However + * I don't know of another way to exit non-zero. + */ + if (exitStatus !== 0) { + process.exit(exitStatus); + } }); } diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index e9b16d7..df190cc 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -42,6 +42,7 @@ var querystring = require('querystring'); var vasync = require('vasync'); var auth = require('smartdc-auth'); +var bunyannoop = require('./bunyannoop'); var errors = require('./errors'); var SaferJsonClient = require('./SaferJsonClient'); @@ -55,21 +56,6 @@ var OS_PLATFORM = os.platform(); -// ---- internal support stuff - -// A no-op bunyan logger shim. -function BunyanNoopLogger() {} -BunyanNoopLogger.prototype.trace = function () {}; -BunyanNoopLogger.prototype.debug = function () {}; -BunyanNoopLogger.prototype.info = function () {}; -BunyanNoopLogger.prototype.warn = function () {}; -BunyanNoopLogger.prototype.error = function () {}; -BunyanNoopLogger.prototype.fatal = function () {}; -BunyanNoopLogger.prototype.child = function () { return this; }; -BunyanNoopLogger.prototype.end = function () {}; - - - // ---- client API /** @@ -111,7 +97,7 @@ function CloudApi(options) { this.account = options.account; this.user = options.user; // optional RBAC subuser this.sign = options.sign; - this.log = options.log || new BunyanNoopLogger(); + this.log = options.log || new bunyannoop.BunyanNoopLogger(); if (!options.version) { options.version = '*'; } @@ -132,6 +118,13 @@ function CloudApi(options) { } +CloudApi.prototype.close = function close(callback) { + this.log.trace({host: this.client.url && this.client.url.host}, + 'close cloudapi http client'); + this.client.close(); +}; + + CloudApi.prototype._getAuthHeaders = function _getAuthHeaders(callback) { assert.func(callback, 'callback'); var self = this; diff --git a/lib/common.js b/lib/common.js index 0908309..2459cb3 100644 --- a/lib/common.js +++ b/lib/common.js @@ -330,19 +330,25 @@ function normShortId(s) { } /* - * take a "profile" object and return a slug based on the account name - * and DC URL. This is currently used to create a filesystem-safe name - * to use for caching + * Take a "profile" object and return a slug based on: account, url and user. + * This is currently used to create a filesystem-safe name to use for caching */ function profileSlug(o) { assert.object(o, 'o'); assert.string(o.account, 'o.account'); assert.string(o.url, 'o.url'); - var acct = o.account.replace(/[@]/g, '_'); + var slug; + var account = o.account.replace(/[@]/g, '_'); var url = o.url.replace(/^https?:\/\//, ''); - var s = format('%s@%s', acct, url).replace(/[!#$%\^&\*:'"\?\/\\\.]/g, '_'); - return s; + if (o.user) { + var user = o.user.replace(/[@]/g, '_'); + slug = format('%s-%s@%s', user, account, url); + } else { + slug = format('%s@%s', account, url); + } + slug = slug.replace(/[!#$%\^&\*:'"\?\/\\\.]/g, '_'); + return slug; } @@ -819,6 +825,28 @@ function deepEqual(a, b) { } +/** + * Resolve "~/..." and "~" to an absolute path. + * + * Limitations: This does not handle "~user/...". This depends on the HOME + * envvar being defined. A better alternative is the "tilde-expansion" + * module (used elsewhere in node-triton), but that doesn't have a sync + * option. + */ +function tildeSync(s) { + var home = process.env.HOME; + if (!home) { + return s; + } else if (s === '~') { + return home; + } else if (s.slice(0, 2) === '~/') { + return home + s.slice(1); + } else { + return s; + } +} + + //---- exports module.exports = { @@ -847,6 +875,7 @@ module.exports = { chomp: chomp, generatePassword: generatePassword, execPlus: execPlus, - deepEqual: deepEqual + deepEqual: deepEqual, + tildeSync: tildeSync }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/config.js b/lib/config.js index bf28ab5..fa76451 100644 --- a/lib/config.js +++ b/lib/config.js @@ -88,7 +88,8 @@ function loadConfig(opts) { assert.object(opts, 'opts'); assert.string(opts.configDir, 'opts.configDir'); - var configPath = configPathFromDir(opts.configDir); + var configDir = common.tildeSync(opts.configDir); + var configPath = configPathFromDir(configDir); var c = fs.readFileSync(DEFAULTS_PATH, 'utf8'); var _defaults = JSON.parse(c); @@ -120,7 +121,7 @@ function loadConfig(opts) { config._user = _user; } config._defaults = _defaults; - config._configDir = opts.configDir; + config._configDir = configDir; return config; } @@ -272,15 +273,18 @@ function _profileFromPath(profilePath, name) { } - function loadProfile(opts) { - assert.string(opts.configDir, 'opts.configDir'); assert.string(opts.name, 'opts.name'); + assert.optionalString(opts.configDir, 'opts.configDir'); if (opts.name === 'env') { return _loadEnvProfile(); + } else if (!opts.configDir) { + throw new errors.TritonError( + 'cannot load profiles (other than "env") without `opts.configDir`'); } else { - var profilePath = path.resolve(opts.configDir, 'profiles.d', + var profilePath = path.resolve( + common.tildeSync(opts.configDir), 'profiles.d', opts.name + '.json'); return _profileFromPath(profilePath, opts.name); } @@ -294,7 +298,7 @@ function loadAllProfiles(opts) { _loadEnvProfile() ]; - var d = path.join(opts.configDir, 'profiles.d'); + var d = path.join(common.tildeSync(opts.configDir), 'profiles.d'); var files = fs.readdirSync(d); files.forEach(function (file) { file = path.join(d, file); diff --git a/lib/index.js b/lib/index.js index c6b357c..87b436e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,11 +6,160 @@ /* * Copyright 2015 Joyent, Inc. - * - * module entry point */ +var assert = require('assert-plus'); + +var bunyannoop = require('./bunyannoop'); +var mod_config = require('./config'); +var tritonapi = require('./tritonapi'); + + +/** + * A convenience wrapper around `tritonapi.createClient` to for simpler usage. + * + * Minimally this only requires that one of `profileName` or `profile` be + * specified. Examples: + * + * var triton = require('triton'); + * var client = triton.createClient({ + * profile: { + * url: "", + * account: "", + * keyId: "" + * } + * }); + * -- + * // Loading a profile from the environment (the `TRITON_*` and/or + * // `SDC_*` environment variables). + * var client = triton.createClient({profileName: 'env'}); + * -- + * var client = triton.createClient({ + * configDir: '~/.triton', // use the CLI's config dir ... + * profileName: 'east1' // ... to find named profiles + * }); + * -- + * // The same thing using the underlying APIs. + * var client = triton.createClient({ + * config: triton.loadConfig({configDir: '~/.triton'}, + * profile: triton.loadProfile({name: 'east1', configDir: '~/.triton'}) + * }); + * + * A more complete example an app using triton internally might want: + * + * var triton = require('triton'); + * var bunyan = require('bunyan'); + * + * var appConfig = { + * // However the app handles its config. + * }; + * var log = bunyan.createLogger({name: 'myapp', component: 'triton'}); + * var client = triton.createClient({ + * log: log, + * profile: appConfig.tritonProfile + * }); + * + * + * TODO: The story for an app wanting to specify some Triton config but NOT + * have to have a triton $configDir/config.json is poor. + * + * @param opts {Object}: + * - @param profile {Object} A *Triton profile* object that includes the + * information required to connect to a CloudAPI -- minimally this: + * { + * "url": "", + * "account": "", + * "keyId": "" + * } + * For example: + * { + * "url": "https://us-east-1.api.joyent.com", + * "account": "billy.bob", + * "keyId": "de:e7:73:9a:aa:91:bb:3e:72:8d:cc:62:ca:58:a2:ec" + * } + * Either `profile` or `profileName` is requires. See discussion above. + * - @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. + * - @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". + * One may not specify both `configDir` and `config`. + * - @param config {Object} A Triton config object loaded by + * `triton.loadConfig(...)`. + * One may not specify both `configDir` and `config`. + * - @param log {Bunyan Logger} Optional. A Bunyan logger. If not provided, + * a stub that does no logging will be used. + */ +function createClient(opts) { + assert.object(opts, 'opts'); + assert.optionalObject(opts.profile, 'opts.profile'); + assert.optionalString(opts.profileName, 'opts.profileName'); + assert.optionalObject(opts.config, 'opts.config'); + assert.optionalString(opts.configDir, 'opts.configDir'); + assert.optionalObject(opts.log, 'opts.log'); + + assert.ok(!(opts.profile && opts.profileName), + 'cannot specify both opts.profile and opts.profileName'); + assert.ok(!(!opts.profile && !opts.profileName), + 'must specify one opts.profile or opts.profileName'); + assert.ok(!(opts.config && opts.configDir), + 'cannot specify both opts.config and opts.configDir'); + assert.ok(!(opts.config && opts.configDir), + 'cannot specify both opts.config and opts.configDir'); + if (opts.profileName && opts.profileName !== 'env') { + assert.ok(opts.configDir, + 'must provide opts.configDir for opts.profileName!="env"'); + } + + var log = opts.log; + if (!opts.log) { + log = new bunyannoop.BunyanNoopLogger(); + } + + var config = opts.config; + if (!config) { + config = mod_config.loadConfig({configDir: opts.configDir}); + } + + var profile = opts.profile; + if (!profile) { + profile = mod_config.loadProfile({ + name: opts.profileName, + configDir: config._configDir + }); + } + // Don't require one to arbitrarily have a profile.name if manually + // creating it. + if (!profile.name) { + // TODO: might want this to be hash or slug of profile params. + profile.name = '_'; + } + mod_config.validateProfile(profile); + + var client = tritonapi.createClient({ + log: log, + config: config, + profile: profile + }); + return client; +} + + module.exports = { - createTritonApiClient: require('./tritonapi').createClient, + createClient: createClient, + + /** + * `createClient` provides convenience parameters to not *have* to call + * the following (i.e. passing in `configDir` and/or `profileName`), but + * some users of node-triton as a module may want to call these directly. + */ + loadConfig: mod_config.loadConfig, + loadProfile: mod_config.loadProfile, + loadAllProfiles: mod_config.loadAllProfiles, + + createTritonApiClient: tritonapi.createClient, + // For those wanting a lower-level raw CloudAPI client. createCloudApiClient: require('./cloudapi2').createClient }; diff --git a/lib/tritonapi.js b/lib/tritonapi.js index 1a41c99..faccfc6 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -92,7 +92,7 @@ function TritonApi(opts) { this.log = opts.log; } - if (this.config.cacheDir) { + if (this.config._configDir) { this.cacheDir = path.resolve(this.config._configDir, this.config.cacheDir, common.profileSlug(this.profile)); @@ -111,6 +111,11 @@ function TritonApi(opts) { } +TritonApi.prototype.close = function close() { + this.cloudapi.close(); + delete this.cloudapi; +}; + TritonApi.prototype._cloudapiFromProfile = function _cloudapiFromProfile(profile) diff --git a/package.json b/package.json index be38744..b6e921c 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": "3.3.1", + "version": "3.4.0", "author": "Joyent (joyent.com)", "dependencies": { "assert-plus": "0.2.0", diff --git a/test/integration/api-images.test.js b/test/integration/api-images.test.js new file mode 100644 index 0000000..992cc89 --- /dev/null +++ b/test/integration/api-images.test.js @@ -0,0 +1,99 @@ +/* + * 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. + */ + +/* + * Integration tests for using image-related APIs as a module. + */ + +var h = require('./helpers'); +var test = require('tape'); + +var common = require('../../lib/common'); + + + +// --- Globals + + + +// --- Tests + +test('TritonApi images', function (tt) { + + var client; + tt.test(' setup: client', function (t) { + client = h.createClient(); + t.ok(client, 'client'); + t.end(); + }); + + var testOpts = {}; + var img; + tt.test(' TritonApi listImages', function (t) { + client.listImages(function (err, images) { + if (h.ifErr(t, err)) + return t.end(); + t.ok(images, 'images'); + t.ok(Array.isArray(images), 'images'); + if (images.length) { + img = images[0]; + t.ok(img, 'img'); + t.ok(common.isUUID(img.id), 'img.id is a UUID'); + t.ok(img.name, 'img.name'); + t.ok(img.version, 'img.version'); + } else { + testOpts.skip = true; + } + t.end(); + }); + }); + + tt.test(' TritonApi getImage by uuid', testOpts, function (t) { + client.getImage(img.id, function (err, image) { + if (h.ifErr(t, err)) + return t.end(); + t.equal(image.id, img.id); + t.end(); + }); + }); + + tt.test(' TritonApi getImage by name', testOpts, function (t) { + client.getImage(img.name, function (err, image) { + if (h.ifErr(t, err)) + return t.end(); + t.equal(image.name, img.name); // might not be the same ID + t.end(); + }); + }); + + tt.test(' TritonApi getImage by name (opts)', testOpts, function (t) { + client.getImage({name: img.name}, function (err, image) { + if (h.ifErr(t, err)) + return t.end(); + t.equal(image.name, img.name); // might not be the same ID + t.end(); + }); + }); + + tt.test(' TritonApi getImage by shortId', testOpts, function (t) { + var shortId = img.id.split('-')[0]; + client.getImage(shortId, function (err, image) { + if (h.ifErr(t, err)) + return t.end(); + t.equal(image.id, img.id); + t.end(); + }); + }); + + tt.test(' teardown: client', function (t) { + client.close(); + t.end(); + }); +}); diff --git a/test/integration/helpers.js b/test/integration/helpers.js index c690b48..6bce274 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -18,7 +18,7 @@ var f = require('util').format; var path = require('path'); var common = require('../../lib/common'); -var mod_config = require('../../lib/config'); +var mod_triton = require('../../'); var testcommon = require('../lib/testcommon'); @@ -41,7 +41,7 @@ try { assert.optionalBool(CONFIG.profile.insecure, 'CONFIG.profile.insecure'); } else if (CONFIG.profileName) { - CONFIG.profile = mod_config.loadProfile({ + CONFIG.profile = mod_triton.loadProfile({ configDir: path.join(process.env.HOME, '.triton'), name: CONFIG.profileName }); @@ -160,11 +160,24 @@ function safeTriton(t, opts, cb) { } +/* + * Create a TritonApi client using the CLI. + */ +function createClient() { + return mod_triton.createClient({ + log: LOG, + profile: CONFIG.profile, + configDir: '~/.triton' // piggy-back on Triton CLI config dir + }); +} + + // --- exports module.exports = { CONFIG: CONFIG, triton: triton, safeTriton: safeTriton, + createClient: createClient, ifErr: testcommon.ifErr };