From d2e999916b77358d355eb09745413173ffbb1acf Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Tue, 8 Sep 2015 09:55:48 -0700 Subject: [PATCH] joyent/node-triton#18 first cut of 'triton profiles' --- README.md | 13 +++ TODO.txt | 22 ----- etc/defaults.json | 2 +- lib/cli.js | 106 +++++++++----------- lib/config.js | 242 ++++++++++++++++++++++++++++++++++++++++----- lib/do_profile.js | 66 ------------- lib/do_profiles.js | 220 +++++++++++++++++++++++++++++++++++++++++ lib/errors.js | 3 +- lib/tritonapi.js | 215 ++++++++++++++++++++++------------------ package.json | 1 + 10 files changed, 623 insertions(+), 267 deletions(-) delete mode 100644 lib/do_profile.js create mode 100644 lib/do_profiles.js diff --git a/README.md b/README.md index 3a168ce..bfd9875 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,18 @@ as a single `DOCKER_HOST`. See the [Triton Docker documentation](https://apidocs.joyent.com/docker) for more information.) +## Configuration + +This section defines all the vars in a TritonApi config. The baked in defaults +are in "etc/defaults.json" and can be overriden for the CLI in +"~/.triton/config.json". + +| Name | Description | +| ---- | ----------- | +| profile | The name of the triton profile to use. The default with the CLI is "env", i.e. take config from `SDC_*` envvars. | +| cacheDir | The path (relative to the config dir, "~/.triton") where cache data is stored. The default is "cache", i.e. the `triton` CLI caches at "~/.triton/cache". | + + ## node-triton differences with node-smartdc - There is a single `triton` command instead of a number of `sdc-*` commands. @@ -251,3 +263,4 @@ clone via: ## License MPL 2.0 + diff --git a/TODO.txt b/TODO.txt index 3b30590..a7b1894 100644 --- a/TODO.txt +++ b/TODO.txt @@ -24,28 +24,6 @@ triton images for 'linux' ones. That might hit that stupid naming problem. -# profiles - -triton profile # list all profiles -triton profile NAME # show NAME profile -triton profile -a NAME # sets it as active -triton profile -n|--new # ??? - -For today: only the implicit 'env' profile. - - - -# config - -~/.triton/ - config.json - {"currProfile": "east3b"} - east3b/ # profile - PROFILE2/ - ... - - - # another day triton config get|set|list # see 'npm config' diff --git a/etc/defaults.json b/etc/defaults.json index ee6f6ca..f510844 100644 --- a/etc/defaults.json +++ b/etc/defaults.json @@ -1,3 +1,3 @@ { - "defaultProfile": "env" + "cacheDir": "cache" } diff --git a/lib/cli.js b/lib/cli.js index 618eb95..0d2369f 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -25,6 +25,7 @@ var path = require('path'); var vasync = require('vasync'); var common = require('./common'); +var mod_config = require('./config'); var errors = require('./errors'); var TritonApi = require('./tritonapi'); @@ -32,16 +33,9 @@ var TritonApi = require('./tritonapi'); //---- globals -var p = console.log; - var pkg = require('../package.json'); -var name = 'triton'; -var log = bunyan.createLogger({ - name: name, - serializers: bunyan.stdSerializers, - stream: process.stderr, - level: 'warn' -}); + +var CONFIG_DIR = path.resolve(process.env.HOME, '.triton'); var OPTIONS = [ { @@ -60,9 +54,13 @@ var OPTIONS = [ help: 'Verbose/debug output.' }, - // XXX disable profile selection for now - //{names: ['profile', 'p'], type: 'string', env: 'TRITON_PROFILE', - // helpArg: 'NAME', help: 'TritonApi client profile to use.'} + { + names: ['profile', 'p'], + type: 'string', + env: 'TRITON_PROFILE', + helpArg: 'NAME', + help: 'Triton client profile to use.' + }, { group: 'CloudApi Options' @@ -127,7 +125,7 @@ var OPTIONS = [ function CLI() { Cmdln.call(this, { - name: pkg.name, + name: 'triton', desc: pkg.description, options: OPTIONS, helpOpts: { @@ -136,6 +134,8 @@ function CLI() { }, helpSubcmds: [ 'help', + // TODO: hide until the command is fully implemented: + // 'profiles', { group: 'Other Commands' }, 'info', 'account', @@ -171,59 +171,52 @@ CLI.prototype.init = function (opts, args, callback) { var self = this; if (opts.version) { - p(this.name, pkg.version); + console.log(this.name, pkg.version); callback(false); return; } this.opts = opts; + + this.log = bunyan.createLogger({ + name: this.name, + serializers: bunyan.stdSerializers, + stream: process.stderr, + level: 'warn' + }); if (opts.verbose) { - log.level('trace'); - log.src = true; + this.log.level('trace'); + this.log.src = true; this.showErrStack = true; } + if (!opts.url && opts.J) { + opts.url = format('https://%s.api.joyent.com', opts.J); + } + this.envProfile = mod_config.loadEnvProfile(opts); + this.configDir = CONFIG_DIR; + this.__defineGetter__('tritonapi', function () { - if (self._tritonapi === undefined) { - var userConfigPath = require('./config').DEFAULT_USER_CONFIG_PATH; - var dir = path.dirname(userConfigPath); - var cacheDir = path.join(dir, 'cache'); - - if (!fs.existsSync(cacheDir)) { - try { - mkdirp.sync(cacheDir); - } catch (e) { - log.info({err: e}, 'failed to make dir %s', cacheDir); - } + if (self._triton === undefined) { + var config = mod_config.loadConfig({ + configDir: self.configDir + }); + self.log.trace({config: config}, 'loaded config'); + var profileName = opts.profile || config.profile || 'env'; + var profile; + if (profileName === 'env') { + profile = self.envProfile; + } else { + profile = mod_config.loadProfile({ + configDir: self.configDir, + name: profileName + }); } - - // XXX support keyId being a priv or pub key path, a la imgapi-cli - // XXX Add TRITON_* envvars. - var envProfile = { - name: 'env', - account: opts.account, - url: opts.url, - keyId: opts.keyId, - insecure: opts.insecure - }; - // If --insecure not given, look at envvar(s) for that. - var specifiedInsecureOpt = opts._order.filter( - function (opt) { return opt.key === 'insecure'; }).length > 0; - if (!specifiedInsecureOpt && process.env.SDC_TESTING) { - envProfile.insecure = common.boolFromString( - process.env.SDC_TESTING, - false, '"SDC_TESTING" envvar'); - } - if (opts.J) { - envProfile.url = format('https://%s.api.joyent.com', opts.J); - } - log.trace({envProfile: envProfile}, 'envProfile'); + self.log.trace({profile: profile}, 'loaded profile'); self._tritonapi = new TritonApi({ - log: log, - profileName: opts.profile, - envProfile: envProfile, - configPath: userConfigPath, - cacheDir: cacheDir + log: self.log, + profile: profile, + config: config }); } return self._tritonapi; @@ -237,7 +230,7 @@ CLI.prototype.init = function (opts, args, callback) { // Meta CLI.prototype.do_completion = require('./do_completion'); -//CLI.prototype.do_profile = require('./do_profile'); +CLI.prototype.do_profiles = require('./do_profiles'); // Other CLI.prototype.do_account = require('./do_account'); @@ -276,8 +269,6 @@ CLI.prototype.do_badger = require('./do_badger'); - - //---- mainline function main(argv) { @@ -324,6 +315,7 @@ function main(argv) { //---- exports module.exports = { + CONFIG_DIR: CONFIG_DIR, CLI: CLI, main: main }; diff --git a/lib/config.js b/lib/config.js index b84acf9..72f49c2 100644 --- a/lib/config.js +++ b/lib/config.js @@ -8,49 +8,107 @@ * Copyright 2015 Joyent, Inc. */ +/* + * This module provides functions to read and write (a) a TritonApi config + * and (b) TritonApi profiles. + * + * The config is a JSON object loaded from "etc/defaults.json" (shipped with + * node-triton) plus possibly overrides from "$configDir/config.json" -- + * which is "~/.triton/config.json" for the `triton` CLI. The config has + * a strict set of allowed keys. + * + * A profile is a small object that includes the necessary info for talking + * to a CloudAPI. E.g.: + * { + * "name": "east1", + * "account": "billy.bob", + * "keyId": "de:e7:73:9a:aa:91:bb:3e:72:8d:cc:62:ca:58:a2:ec", + * "url": "https://us-east-1.api.joyent.com" + * } + * + * Profiles are stored as separate JSON files in + * "$configDir/profiles.d/$name.json". Typically `triton profiles ...` is + * used to manage them. In addition there is the special "env" profile that + * is constructed from the "SDC_*" environment variables. + */ + var assert = require('assert-plus'); var format = require('util').format; var fs = require('fs'); +var mkdirp = require('mkdirp'); +var glob = require('glob'); var path = require('path'); +var vasync = require('vasync'); var common = require('./common'); var errors = require('./errors'); -var DEFAULT_USER_CONFIG_PATH = path.resolve(process.env.HOME, - '.triton', 'config.json'); var DEFAULTS_PATH = path.resolve(__dirname, '..', 'etc', 'defaults.json'); -var OVERRIDE_KEYS = []; // config object keys to do a one-level deep override +var OVERRIDE_NAMES = []; // config object keys to do a one-level deep override + +// TODO: use this const to create the "Configuration" docs table. +var CONFIG_VAR_NAMES = [ + 'profile', + 'cacheDir' +]; +// --- internal support stuff + +// TODO: improve this validation +function _validateProfile(profile) { + assert.object(profile, 'profile'); + assert.string(profile.name, 'profile.name'); + assert.string(profile.url, 'profile.url'); + assert.string(profile.account, 'profile.account'); + assert.string(profile.keyId, 'profile.keyId'); + assert.optionalBool(profile.insecure, 'profile.insecure'); + // TODO: error on extraneous params +} + + +function configPathFromDir(configDir) { + return path.resolve(configDir, 'config.json'); +} + + +// --- Config + /** - * Load the Triton client config. This is a merge of the built-in "defaults" (at - * etc/defaults.json) and the "user" config (at ~/.triton/config.json if it - * exists). + * Load the TritonApi config. This is a merge of the built-in "defaults" (at + * etc/defaults.json) and the "user" config (at "$configDir/config.json", + * typically "~/.triton/config.json", if it exists). * - * This includes some internal data on keys with a leading underscore. + * 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 + * + * @returns {Object} The loaded config. */ -function loadConfigSync(opts) { +function loadConfig(opts) { assert.object(opts, 'opts'); - assert.string(opts.configPath, 'opts.configPath'); - assert.optionalObject(opts.envProfile, 'opts.envProfile'); + assert.string(opts.configDir, 'opts.configDir'); + + var configPath = configPathFromDir(opts.configDir); var c = fs.readFileSync(DEFAULTS_PATH, 'utf8'); var _defaults = JSON.parse(c); var config = JSON.parse(c); - if (opts.configPath && fs.existsSync(opts.configPath)) { - c = fs.readFileSync(opts.configPath, 'utf8'); + if (fs.existsSync(configPath)) { + c = fs.readFileSync(configPath, 'utf8'); var _user = JSON.parse(c); var userConfig = JSON.parse(c); if (typeof (userConfig) !== 'object' || Array.isArray(userConfig)) { throw new errors.ConfigError( - format('"%s" is not an object', opts.configPath)); + format('"%s" is not an object', configPath)); } // These special keys are merged into the key of the same name in the // base "defaults.json". Object.keys(userConfig).forEach(function (key) { - if (~OVERRIDE_KEYS.indexOf(key) && config[key] !== undefined) { + if (~OVERRIDE_NAMES.indexOf(key) && config[key] !== undefined) { Object.keys(userConfig[key]).forEach(function (subKey) { if (userConfig[key][subKey] === null) { delete config[key][subKey]; @@ -66,24 +124,160 @@ function loadConfigSync(opts) { config._user = _user; } config._defaults = _defaults; - - // Add 'env' profile, if given. - if (opts.envProfile) { - if (!config.profiles) { - config.profiles = []; - } - config.profiles.push(opts.envProfile); - } + config._configDir = opts.configDir; return config; } +function setConfigVar(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.configDir, 'opts.configDir'); + assert.string(opts.name, 'opts.name'); + assert.string(opts.value, 'opts.value'); + assert.ok(opts.name.indexOf('.') === -1, + 'dotted config name not yet supported'); + assert.ok(CONFIG_VAR_NAMES.indexOf(opts.name) !== -1, + 'unknown config var name: ' + opts.name); + + var configPath = configPathFromDir(opts.configDir); + + var config; + vasync.pipeline({funcs: [ + function loadExisting(_, next) { + fs.exists(configPath, function (exists) { + if (!exists) { + config = {}; + return next(); + } + fs.readFile(configPath, function (err, data) { + if (err) { + return next(err); + } + try { + config = JSON.parse(data); + } catch (e) { + return next(e); + } + next(); + }); + }); + }, + + function mkConfigDir(_, next) { + fs.exists(opts.configDir, function (exists) { + if (!exists) { + mkdirp(opts.configDir, next); + } else { + next(); + } + }); + }, + + function updateAndSave(_, next) { + config[opts.name] = opts.value; + fs.writeFile(configPath, JSON.stringify(config, null, 4), next); + } + ]}, cb); +} + + + +// --- Profiles + +/** + * Load the special 'env' profile, which handles some details of getting + * values from envvars. *Most* of that is done already via the + * `opts` dashdash Options object. + * + * @returns {Object} The 'env' profile. + */ +function loadEnvProfile(opts) { + // XXX support keyId being a priv or pub key path, a la imgapi-cli + // XXX Add TRITON_* envvars. + var envProfile = { + name: 'env', + account: opts.account, + url: opts.url, + keyId: opts.keyId, + insecure: opts.insecure + }; + // If --insecure not given, look at envvar(s) for that. + var specifiedInsecureOpt = opts._order.filter( + function (opt) { return opt.key === 'insecure'; }).length > 0; + if (!specifiedInsecureOpt && process.env.SDC_TESTING) { + envProfile.insecure = common.boolFromString( + process.env.SDC_TESTING, + false, '"SDC_TESTING" envvar'); + } + + _validateProfile(envProfile); + + return envProfile; +} + +function _profileFromPath(profilePath, name) { + if (! fs.existsSync(profilePath)) { + throw new errors.ConfigError('no such profile: ' + name); + } + var profile; + try { + profile = JSON.parse(fs.readFileSync(profilePath, 'utf8')); + } catch (e) { + throw new errors.ConfigError(e, format( + 'error in "%s" profile: %s: %s', name, + profilePath, e.message)); + } + profile.name = name; + + _validateProfile(profile); + + return profile; +} + +function loadProfile(opts) { + assert.string(opts.configDir, 'opts.configDir'); + assert.string(opts.name, 'opts.name'); + + var profilePath = path.resolve(opts.configDir, 'profiles.d', + opts.name + '.json'); + return _profileFromPath(profilePath, opts.name); +} + +function loadAllProfiles(opts) { + assert.string(opts.configDir, 'opts.configDir'); + assert.object(opts.log, 'opts.log'); + + var profiles = []; + var files = glob.sync(path.resolve(opts.configDir, + 'profiles.d', '*.json')); + for (var i = 0; i < files.length; i++) { + var file = files[i]; + var name = path.basename(file).slice(0, - path.extname(file).length); + if (name.toLowerCase() === 'env') { + // Skip the special 'env'. + opts.log.debug('skip reserved name "env" profile: %s', file); + continue; + } + try { + profiles.push(_profileFromPath(file, name)); + } catch (e) { + opts.log.warn({err: e, profilePath: file}, + 'error loading profile; skipping'); + } + } + + return profiles; +} + //---- exports module.exports = { - DEFAULT_USER_CONFIG_PATH: DEFAULT_USER_CONFIG_PATH, - loadConfigSync: loadConfigSync + loadConfig: loadConfig, + setConfigVar: setConfigVar, + loadEnvProfile: loadEnvProfile, + loadProfile: loadProfile, + loadAllProfiles: loadAllProfiles }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_profile.js b/lib/do_profile.js deleted file mode 100644 index 03c39b0..0000000 --- a/lib/do_profile.js +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 profile ...` - */ - -var common = require('./common'); - - - -function do_profile(subcmd, opts, args, callback) { - if (opts.help) { - this.do_help('help', {}, [subcmd], callback); - return; - } else if (args.length > 1) { - return callback(new Error('too many args: ' + args)); - } - - var profs = common.deepObjCopy(this.sdc.profiles); - var currProfileName = this.sdc.profile.name; - for (var i = 0; i < profs.length; i++) { - profs[i].curr = (profs[i].name === currProfileName ? '*' : ' '); - profs[i].dcs = (profs[i].dcs ? profs[i].dcs : ['all']) - .join(','); - } - if (opts.json) { - common.jsonStream(profs); - } else { - common.tabulate(profs, { - columns: 'curr,name,dcs,user,keyId', - sort: 'name,user', - validFields: 'curr,name,dcs,user,keyId' - }); - } - callback(); -} - -do_profile.options = [ - { - names: ['help', 'h'], - type: 'bool', - help: 'Show this help.' - }, - { - names: ['json', 'j'], - type: 'bool', - help: 'JSON output.' - } -]; -do_profile.help = ( - 'Create, update or inpect joyent CLI profiles.\n' - + '\n' - + 'Usage:\n' - + ' {{name}} profile\n' - + '\n' - + '{{options}}' -); - - -module.exports = do_profile; diff --git a/lib/do_profiles.js b/lib/do_profiles.js new file mode 100644 index 0000000..eb20c3e --- /dev/null +++ b/lib/do_profiles.js @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2015 Joyent Inc. All rights reserved. + * + * `triton profiles ...` + */ + +var common = require('./common'); +var errors = require('./errors'); +var mod_config = require('./config'); +var tabula = require('tabula'); + + +var sortDefault = 'name'; +var columnsDefault = 'name,curr,account,url'; +var columnsDefaultLong = 'name,curr,account,url,insecure,keyId'; + +function _listProfiles(_, opts, cb) { + var columns = columnsDefault; + if (opts.o) { + columns = opts.o; + } else if (opts.long) { + columns = columnsDefaultLong; + } + columns = columns.split(','); + + var sort = opts.s.split(','); + + // Load all the profiles. "env" is a special one managed by the CLI. + var profiles; + try { + profiles = mod_config.loadAllProfiles({ + configDir: this.tritonapi.config._configDir, + log: this.log + }); + } catch (e) { + return cb(e); + } + profiles.push(this.envProfile); + + // Display. + var i; + if (opts.json) { + for (i = 0; i < profiles.length; i++) { + profiles[i].curr = (profiles[i].name === + this.tritonapi.profile.name); + } + common.jsonStream(profiles); + } else { + for (i = 0; i < profiles.length; i++) { + profiles[i].curr = (profiles[i].name === this.tritonapi.profile.name + ? '*' : ''); + } + tabula(profiles, { + skipHeader: opts.H, + columns: columns, + sort: sort + }); + } + cb(); +} + +function _currentProfile(profile, opts, cb) { + if (this.tritonapi.profile.name === profile.name) { + console.log('"%s" is already the current profile', profile.name); + return cb(); + } + + mod_config.setConfigVar({ + configDir: this.configDir, + name: 'profile', + value: profile.name + }, function (err) { + if (err) { + return cb(err); + } + console.log('Switched to "%s" profile', profile.name); + cb(); + }); +} + +// TODO: finish the implementation +//function _addProfile(profile, opts, cb) { +//} +// +//function _editProfile(profile, opts, cb) { +//} +// +//function _deleteProfile(profile, opts, cb) { +//} + + +function do_profiles(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + var actions = []; + if (opts.add) { actions.push('add'); } + if (opts.current) { actions.push('current'); } + if (opts.edit) { actions.push('edit'); } + if (opts['delete']) { actions.push('delete'); } + var action; + if (actions.length === 0) { + action = 'list'; + } else if (actions.length > 1) { + return cb(new errors.UsageError( + 'only one action option may be used at once')); + } else { + action = actions[0]; + } + + var name; + switch (action) { + case 'add': + if (args.length === 1) { + name = args[0]; + } else if (args.length > 1) { + return cb(new errors.UsageError('too many args')); + } + break; + case 'list': + case 'current': + case 'edit': + case 'delete': + name = opts.current || opts.edit || opts['delete']; + if (args.length > 0) { + return cb(new errors.UsageError('too many args')); + } + break; + default: + throw new Error('unknown action: ' + action); + } + + var profile; + if (name) { + if (name === 'env') { + profile = this.envProfile; + } else { + profile = mod_config.loadProfile({ + configDir: this.configDir, + name: name + }); + } + } + + var func = { + list: _listProfiles, + current: _currentProfile + // TODO: finish the implementation + //add: _addProfile, + //edit: _editProfile, + //'delete': _deleteProfile + }[action].bind(this); + func(profile, opts, cb); +} + +do_profiles.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + group: 'Action Options' + }, + { + names: ['current', 'c'], + type: 'string', + helpArg: 'NAME', + help: 'Switch to the given profile.' + } + // TODO: finish the implementation + //{ + // names: ['add', 'a'], + // type: 'bool', + // help: 'Add a new profile.' + //}, + //{ + // names: ['edit', 'e'], + // type: 'string', + // helpArg: 'NAME', + // help: 'Edit profile NAME in your $EDITOR.' + //}, + //{ + // names: ['delete', 'd'], + // type: 'string', + // helpArg: 'NAME', + // help: 'Delete profile NAME.' + //} + +].concat(common.getCliTableOptions({ + includeLong: true, + sortDefault: sortDefault +})); +do_profiles.help = [ + 'List and update `triton` CLI profiles.', + '', + 'A profile is a configured Triton CloudAPI endpoint. I.e. the', + 'url, account, key, etc. information required to call a CloudAPI.', + 'You can then switch between profiles with `triton -p PROFILE`', + 'or the TRITON_PROFILE environment variable.', + '', + 'The "CURR" column indicates which profile is the current one.', + '', + 'Usage:', + ' {{name}} profiles # list profiles', + ' {{name}} profiles -c|--current NAME # set NAME as current profile', + // TODO: finish the implementation + //' {{name}} profiles -a|--add [NAME] # add a new profile', + //' {{name}} profiles -e|--edit NAME # edit a profile in $EDITOR', + //' {{name}} profiles -d|--delete NAME # delete a profile', + '', + '{{options}}' +].join('\n'); + +do_profiles.hidden = true; // TODO: until -a,-e,-d are implemented + + +module.exports = do_profiles; diff --git a/lib/errors.js b/lib/errors.js index 676b82f..cceab7d 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -14,7 +14,6 @@ var util = require('util'), format = util.format; var assert = require('assert-plus'); var verror = require('verror'), - WError = verror.WError, VError = verror.VError; @@ -36,7 +35,7 @@ function TritonError(options) { var args = []; if (options.cause) args.push(options.cause); args.push(options.message); - WError.apply(this, args); + VError.apply(this, args); var extra = Object.keys(options).filter( function (k) { return ['cause', 'message'].indexOf(k) === -1; }); diff --git a/lib/tritonapi.js b/lib/tritonapi.js index cabe078..99cae9d 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -38,50 +38,45 @@ var loadConfigSync = require('./config').loadConfigSync; /** * Create a TritonApi client. * - * @param options {Object} + * @param opts {Object} * - log {Bunyan Logger} - * - profileName {String} Optional. Name of profile to use. Defaults to - * 'defaultProfile' in the config. - * - envProfile {Object} Optional. A starter 'env' profile object. Missing - * fields will be filled in from standard SDC_* envvars. * ... */ -function TritonApi(options) { - assert.object(options, 'options'); - assert.object(options.log, 'options.log'); - assert.optionalString(options.profileName, 'options.profileName'); - assert.optionalString(options.configPath, 'options.configPath'); - assert.optionalString(options.cacheDir, 'options.cacheDir'); - assert.optionalObject(options.envProfile, 'options.envProfile'); +function TritonApi(opts) { + assert.object(opts, 'opts'); + assert.object(opts.log, 'opts.log'); + assert.object(opts.profile, 'opts.profile'); + assert.object(opts.config, 'opts.config'); + + this.profile = opts.profile; + this.config = opts.config; // Make sure a given bunyan logger has reasonable client_re[qs] serializers. // Note: This was fixed in restify, then broken again in // https://github.com/mcavage/node-restify/pull/501 - if (options.log.serializers && - (!options.log.serializers.client_req || - !options.log.serializers.client_req)) { - this.log = options.log.child({ + if (opts.log.serializers && + (!opts.log.serializers.client_req || + !opts.log.serializers.client_req)) { + this.log = opts.log.child({ serializers: restifyBunyanSerializers }); } else { - this.log = options.log; + this.log = opts.log; } - this.config = loadConfigSync({ - configPath: options.configPath, - envProfile: options.envProfile - }); - this.profiles = this.config.profiles; - this.profile = this.getProfile( - options.profileName || this.config.defaultProfile); - this.log.trace({profile: this.profile}, 'profile data'); - if (options.cacheDir !== undefined) { - var slug = common.slug(this.profile); - this.cacheDir = path.join(options.cacheDir, slug); + if (this.config.cacheDir) { + this.cacheDir = path.resolve(this.config._configDir, + this.config.cacheDir, + common.slug(this.profile)); this.log.trace({cacheDir: this.cacheDir}, 'cache dir'); - try { - mkdirp.sync(this.cacheDir); - } catch (e) {} + // TODO perhaps move this to an async .init() + if (!fs.existsSync(this.cacheDir)) { + try { + mkdirp.sync(this.cacheDir); + } catch (e) { + throw e; + } + } } this.cloudapi = this._cloudapiFromProfile(this.profile); @@ -89,17 +84,9 @@ function TritonApi(options) { -TritonApi.prototype.getProfile = function getProfile(name) { - for (var i = 0; i < this.profiles.length; i++) { - if (this.profiles[i].name === name) { - return this.profiles[i]; - } - } -}; - - TritonApi.prototype._cloudapiFromProfile = - function _cloudapiFromProfile(profile) { + function _cloudapiFromProfile(profile) +{ assert.object(profile, 'profile'); assert.string(profile.account, 'profile.account'); assert.string(profile.keyId, 'profile.keyId'); @@ -133,6 +120,56 @@ TritonApi.prototype._cloudapiFromProfile = return client; }; + +TritonApi.prototype._cachePutJson = function _cachePutJson(key, obj, cb) { + var self = this; + assert.string(this.cacheDir, 'this.cacheDir'); + assert.string(key, 'key'); + assert.object(obj, 'obj'); + assert.func(cb, 'cb'); + + var keyPath = path.resolve(this.cacheDir, key); + var data = JSON.stringify(obj); + fs.writeFile(keyPath, data, {encoding: 'utf8'}, function (err) { + if (err) { + self.log.info({err: err, keyPath: keyPath}, 'error caching'); + } + cb(); + }); +}; + +TritonApi.prototype._cacheGetJson = function _cacheGetJson(key, cb) { + var self = this; + assert.string(this.cacheDir, 'this.cacheDir'); + assert.string(key, 'key'); + assert.func(cb, 'cb'); + + var keyPath = path.resolve(this.cacheDir, key); + fs.exists(keyPath, function (exists) { + if (!exists) { + self.log.trace({keyPath: keyPath}, 'cache file does not exist'); + return cb(); + } + fs.readFile(keyPath, 'utf8', function (err, data) { + if (err) { + self.log.warn({err: err, keyPath: keyPath}, + 'error reading cache file'); + return cb(); + } + var obj; + try { + obj = JSON.parse(data); + } catch (dataErr) { + self.log.warn({err: dataErr, keyPath: keyPath}, + 'error parsing JSON cache file'); + return cb(); + } + cb(null, obj); + }); + }); +}; + + /** * cloudapi listImages wrapper with optional caching */ @@ -146,69 +183,57 @@ TritonApi.prototype.listImages = function listImages(opts, cb) { assert.optionalBool(opts.useCache, 'opts.useCache'); assert.func(cb, 'cb'); - var cacheFile; - if (self.cacheDir) - cacheFile = path.join(self.cacheDir, 'images.json'); + var cacheKey = 'images.json'; + var cached; + var fetched; + var res; - if (opts.useCache && !cacheFile) { - cb(new Error('opts.useCache set but no cacheDir found')); - return; - } - - // try to read the cache if the user wants it - // if this fails for any reason we fallback to hitting the cloudapi - if (opts.useCache) { - self.log.debug({file: cacheFile}, 'reading images cache file'); - fs.readFile(cacheFile, 'utf8', function (err, out) { - if (err) { - self.log.warn({err: err, cacheFile: cacheFile}, - 'failed to read cache file'); - fetch(); - return; + vasync.pipeline({funcs: [ + function tryCache(_, next) { + if (!opts.useCache) { + return next(); } - var data; - try { - data = JSON.parse(out); - } catch (e) { - self.log.warn({err: e, cacheFile: cacheFile}, - 'failed to parse cache file'); - fetch(); - return; + self._cacheGetJson(cacheKey, function (err, cached_) { + if (err) { + return next(err); + } + cached = cached_; + next(); + }); + }, + + function listImgs(_, next) { + if (cached) { + return next(); } - self.log.info('calling back with images cache data'); - cb(null, data, {}); - }); - return; - } + self.cloudapi.listImages(opts, function (err, imgs, res_) { + if (err) { + return next(err); + } + fetched = imgs; + res = res_; + next(); + }); + }, - fetch(); - function fetch() { - self.cloudapi.listImages(opts, function (err, imgs, res) { - if (!err && self.cacheDir) { - // cache the results - var data = JSON.stringify(imgs); - fs.writeFile(cacheFile, data, {encoding: 'utf8'}, - function (err2) { - if (err2) { - self.log.info({err: err2}, - 'error caching images results'); - } - self.log.trace({file: cacheFile}, 'images cache updated'); - done(); - }); - } else { - done(); + function cacheFetched(_, next) { + if (!fetched) { + return next(); } + self._cachePutJson(cacheKey, fetched, next); + } - - function done() { - cb(err, imgs, res); - } - }); - } + ]}, function (err) { + if (err) { + cb(err, null, res); + } else { + cb(null, fetched || cached, res); + } + }); }; + /** * Get an image by ID, exact name, or short ID, in that order. * diff --git a/package.json b/package.json index 58d2375..5207b3c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "cmdln": "3.3.0", "dashdash": "1.10.0", "extsprintf": "1.0.2", + "glob": "5.0.14", "lomstream": "1.1.0", "mkdirp": "0.5.1", "node-uuid": "1.4.3",