a start at 'triton profiles'

This commit is contained in:
Trent Mick 2015-09-08 09:55:48 -07:00
parent 3699dd3a46
commit 2baf40d841
9 changed files with 400 additions and 243 deletions

View File

@ -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

View File

@ -1,3 +1,3 @@
{
"defaultProfile": "env"
"cacheDir": "cache"
}

View File

@ -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,7 @@ function CLI() {
},
helpSubcmds: [
'help',
'profiles',
{ group: 'Other Commands' },
'info',
'account',
@ -171,59 +170,51 @@ 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.__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: CONFIG_DIR
});
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: CONFIG_DIR,
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 +228,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 +267,6 @@ CLI.prototype.do_badger = require('./do_badger');
//---- mainline
function main(argv) {
@ -324,6 +313,7 @@ function main(argv) {
//---- exports
module.exports = {
CONFIG_DIR: CONFIG_DIR,
CLI: CLI,
main: main
};

View File

@ -11,41 +11,62 @@
var assert = require('assert-plus');
var format = require('util').format;
var fs = require('fs');
var glob = require('glob');
var path = require('path');
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
// --- 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
}
// --- exported functions
/**
* 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 = path.resolve(opts.configDir, 'config.json');
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".
@ -66,24 +87,104 @@ 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;
}
/**
* 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,
loadEnvProfile: loadEnvProfile,
loadProfile: loadProfile,
loadAllProfiles: loadAllProfiles
};
// vim: set softtabstop=4 shiftwidth=4:

View File

@ -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;

95
lib/do_profiles.js Normal file
View File

@ -0,0 +1,95 @@
/*
* Copyright (c) 2015 Joyent Inc. All rights reserved.
*
* `triton profile ...`
*/
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 do_profiles(subcmd, opts, args, cb) {
if (opts.help) {
this.do_help('help', {}, [subcmd], cb);
return;
} else if (args.length > 1) {
return cb(new errors.UsageError('too many args'));
}
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.triton.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.triton.profile.name);
}
common.jsonStream(profiles);
} else {
for (i = 0; i < profiles.length; i++) {
profiles[i].curr = (profiles[i].name === this.triton.profile.name
? '*' : '');
}
tabula(profiles, {
skipHeader: opts.H,
columns: columns,
sort: sort
});
}
cb();
}
do_profiles.options = [
{
names: ['help', 'h'],
type: 'bool',
help: 'Show this help.'
}
].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',
'',
'{{options}}'
].join('\n');
module.exports = do_profiles;

View File

@ -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; });

View File

@ -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');
// TODO perhaps move this to an async .init()
if (!fs.existsSync(this.cacheDir)) {
try {
mkdirp.sync(this.cacheDir);
} catch (e) {}
} 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,55 @@ TritonApi.prototype._cloudapiFromProfile =
return client;
};
Triton.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();
});
};
Triton.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 +182,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;
vasync.pipeline({funcs: [
function tryCache(_, next) {
if (!opts.useCache) {
return next();
}
// 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) {
self._cacheGetJson(cacheKey, function (err, cached_) {
if (err) {
self.log.warn({err: err, cacheFile: cacheFile},
'failed to read cache file');
fetch();
return;
return next(err);
}
var data;
try {
data = JSON.parse(out);
} catch (e) {
self.log.warn({err: e, cacheFile: cacheFile},
'failed to parse cache file');
fetch();
return;
cached = cached_;
next();
});
},
function listImgs(_, next) {
if (cached) {
return next();
}
self.log.info('calling back with images cache data');
cb(null, data, {});
self.cloudapi.listImages(opts, function (err, imgs, res_) {
if (err) {
return next(err);
}
fetched = imgs;
res = res_;
next();
});
return;
},
function cacheFetched(_, next) {
if (!fetched) {
return next();
}
self._cachePutJson(cacheKey, fetched, 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();
});
]}, function (err) {
if (err) {
cb(err, null, res);
} else {
done();
}
function done() {
cb(err, imgs, res);
cb(null, fetched || cached, res);
}
});
}
};
/**
* Get an image by ID, exact name, or short ID, in that order.
*

View File

@ -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",