From 8cd4dd80ebf1f78cf72613bbc17cb2d7e8926676 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Fri, 18 Mar 2016 16:51:22 -0700 Subject: [PATCH] joyent/node-triton#46 Triton help for setting up and using Docker against a Triton DC This implements all but step #4 (`triton docker ...`) in the proposal. --- CHANGES.md | 29 ++- lib/cli.js | 66 ++++- lib/cloudapi2.js | 16 ++ lib/config.js | 2 +- lib/do_env.js | 75 ++++-- lib/do_profile/do_create.js | 45 +++- lib/do_profile/do_docker_setup.js | 58 +++++ lib/do_profile/do_get.js | 9 +- lib/do_profile/do_list.js | 11 +- lib/do_profile/index.js | 4 +- lib/do_profile/profilecommon.js | 401 +++++++++++++++++++++++++++++- lib/errors.js | 23 +- package.json | 6 +- 13 files changed, 698 insertions(+), 47 deletions(-) create mode 100644 lib/do_profile/do_docker_setup.js diff --git a/CHANGES.md b/CHANGES.md index 43aa2f6..84a4943 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,9 +5,34 @@ Known issues: - `triton ssh ...` disables ssh ControlMaster to avoid issue #52. -## 4.8.1 (not yet released) +## 4.9.0 (not yet released) -(nothing yet) +- #46 Initial support for `triton` helping setup and manage configuration for + using `docker` against a Triton datacenter. Triton datacenters can provide + a Docker Remote API endpoint against which you can run the normal `docker` + client. See for and overview and + for developer details. + + - `triton profile create` will now setup the profile for running Docker, + if the Triton datacenter provides a docker endpoint. The typical flow + would be: + + $ triton profile create + name: foo + ... + $ triton profile set foo # make foo my default profile + $ eval "$(triton env --docker)" # set 'DOCKER_' envvars + $ docker info + + - For existing Triton CLI profiles, there is a new `triton profile + docker-setup [PROFILE]`. + + $ triton profile docker-setup + $ eval "$(triton env --docker)" + $ docker info + + - `triton env` will now emit commands to setup `DOCKER_` envvars. That + can be limited to just the Docker-relevant env via `triton env --docker`. ## 4.8.0 diff --git a/lib/cli.js b/lib/cli.js index 91b2f17..70b756b 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -276,25 +276,38 @@ CLI.prototype.init = function (opts, args, callback) { this.configDir = CONFIG_DIR; - this.__defineGetter__('tritonapi', function () { - if (self._tritonapi === undefined) { - var config = mod_config.loadConfig({ + this.__defineGetter__('config', function getConfig() { + if (self._config === undefined) { + self._config = mod_config.loadConfig({ configDir: self.configDir }); - self.log.trace({config: config}, 'loaded config'); + self.log.trace({config: self._config}, 'loaded config'); + } + return self._config; + }); - var profileName = opts.profile || config.profile || 'env'; - var profile = mod_config.loadProfile({ + this.__defineGetter__('profileName', function getProfileName() { + return (opts.profile || self.config.profile || 'env'); + }); + + this.__defineGetter__('profile', function getProfile() { + if (self._profile === undefined) { + self._profile = mod_config.loadProfile({ configDir: self.configDir, - name: profileName + name: self.profileName }); - self._applyProfileOverrides(profile); - self.log.trace({profile: profile}, 'loaded profile'); + self._applyProfileOverrides(self._profile); + self.log.trace({profile: self._profile}, 'loaded profile'); + } + return self._profile; + }); + this.__defineGetter__('tritonapi', function getTritonapi() { + if (self._tritonapi === undefined) { self._tritonapi = tritonapi.createClient({ log: self.log, - profile: profile, - config: config + profile: self.profile, + config: self.config }); } return self._tritonapi; @@ -538,6 +551,37 @@ CLI.prototype._applyProfileOverrides = }; +/* + * Create and return a TritonApi instance for the given profile name and using + * the CLI's config. Callers of this should remember to `tritonapi.close()` + * when complete... otherwise an HTTP Agent using keep-alive will keep node + * from exiting until it times out. + */ +CLI.prototype.tritonapiFromProfileName = + function tritonapiFromProfileName(opts) { + assert.object(opts, 'opts'); + assert.string(opts.profileName, 'opts.profileName'); + + var profile; + if (opts.profileName === this.profileName) { + profile = this.profile; + } else { + profile = mod_config.loadProfile({ + configDir: this.configDir, + name: opts.profileName + }); + this.log.trace({profile: profile}, + 'tritonapiFromProfileName: loaded profile'); + } + + return tritonapi.createClient({ + log: this.log, + profile: profile, + config: this.config + }); +}; + + // Meta CLI.prototype.do_completion = require('./do_completion'); CLI.prototype.do_profiles = require('./do_profiles'); diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 54998df..2a26eb4 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -286,6 +286,22 @@ CloudApi.prototype._passThrough = function _passThrough(endpoint, opts, cb) { }; +// ---- ping + +CloudApi.prototype.ping = function ping(opts, cb) { + assert.object(opts, 'opts'); + assert.func(cb, 'cb'); + + var reqOpts = { + path: '/--ping', + // Ping should be fast. We don't want 15s of retrying. + retry: false + }; + this.client.get(reqOpts, function (err, req, res, body) { + cb(err, body, res); + }); +}; + // ---- networks diff --git a/lib/config.js b/lib/config.js index 94fbc88..10f22c2 100644 --- a/lib/config.js +++ b/lib/config.js @@ -424,7 +424,7 @@ function saveProfileSync(opts) { mkdirp.sync(path.dirname(profilePath)); } fs.writeFileSync(profilePath, JSON.stringify(toSave, null, 4), 'utf8'); - console.log('Saved profile "%s"', name); + console.log('Saved profile "%s".', name); } diff --git a/lib/do_env.js b/lib/do_env.js index 900a7a6..9fb6211 100644 --- a/lib/do_env.js +++ b/lib/do_env.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 Joyent Inc. + * Copyright 2016 Joyent Inc. * * `triton env ...` */ @@ -7,6 +7,7 @@ var assert = require('assert-plus'); var format = require('util').format; var fs = require('fs'); +var path = require('path'); var strsplit = require('strsplit'); var sshpk = require('sshpk'); var vasync = require('vasync'); @@ -18,6 +19,7 @@ var mod_config = require('./config'); function do_env(subcmd, opts, args, cb) { + var self = this; if (opts.help) { this.do_help('help', {}, [subcmd], cb); return; @@ -27,16 +29,27 @@ function do_env(subcmd, opts, args, cb) { } var profileName = args[0] || this.tritonapi.profile.name; - var allClientTypes = ['smartdc', 'triton']; + var allClientTypes = ['triton', 'docker', 'smartdc']; var clientTypes = []; - if (opts.smartdc) { - clientTypes.push('smartdc'); - } + var explicit; + var shortOpts = ''; if (opts.triton) { + shortOpts += 't'; clientTypes.push('triton'); } + if (opts.docker) { + shortOpts += 'd'; + clientTypes.push('docker'); + } + if (opts.smartdc) { + shortOpts += 's'; + clientTypes.push('smartdc'); + } if (clientTypes.length === 0) { + explicit = false; clientTypes = allClientTypes; + } else { + explicit = true; } try { @@ -52,15 +65,40 @@ function do_env(subcmd, opts, args, cb) { } var p = console.log; - var shortOpts = ''; clientTypes.forEach(function (clientType) { switch (clientType) { case 'triton': - shortOpts += 't'; p('export TRITON_PROFILE="%s"', profile.name); break; + case 'docker': + var setupJson = path.resolve(self.configDir, 'docker', + common.profileSlug(profile), 'setup.json'); + if (fs.existsSync(setupJson)) { + var setup; + try { + setup = JSON.parse(fs.readFileSync(setupJson)); + } catch (err) { + cb(new errors.ConfigError(err, format( + 'error determining Docker environment from "%s": %s', + setupJson, err))); + return; + } + Object.keys(setup.env).forEach(function (key) { + var val = setup.env[key]; + if (val === null) { + p('unset %s', key); + } else { + p('export %s=%s', key, val); + } + }); + } else if (explicit) { + cb(new errors.ConfigError(format('could not find Docker ' + + 'environment setup for profile "%s":\n Run `triton ' + + 'profile docker-setup %s` to setup.', + profile.name, profile.name))); + } + break; case 'smartdc': - shortOpts += 's'; p('export SDC_URL="%s"', profile.url); p('export SDC_ACCOUNT="%s"', profile.account); if (profile.user) { @@ -82,8 +120,10 @@ function do_env(subcmd, opts, args, cb) { }); p('# Run this command to configure your shell:'); - p('# eval "$(triton env%s %s)"', - (shortOpts ? ' -'+shortOpts : ''), profile.name); + p('# eval "$(triton env%s%s)"', + (shortOpts ? ' -'+shortOpts : ''), + (profile.name === this.tritonapi.profile.name + ? '' : ' ' + profile.name)); } do_env.options = [ @@ -92,16 +132,21 @@ do_env.options = [ type: 'bool', help: 'Show this help.' }, - { - names: ['smartdc', 's'], - type: 'bool', - help: 'Emit environment for node-smartdc (i.e. the "SDC_*" variables).' - }, { names: ['triton', 't'], type: 'bool', help: 'Emit environment commands for node-triton itself (i.e. the ' + '"TRITON_PROFILE" variable).' + }, + { + names: ['docker', 'd'], + type: 'bool', + help: 'Emit environment commands for docker ("DOCKER_HOST" et al).' + }, + { + names: ['smartdc', 's'], + type: 'bool', + help: 'Emit environment for node-smartdc (i.e. the "SDC_*" variables).' } ]; diff --git a/lib/do_profile/do_create.js b/lib/do_profile/do_create.js index 39c72fe..493ad05 100644 --- a/lib/do_profile/do_create.js +++ b/lib/do_profile/do_create.js @@ -13,12 +13,14 @@ var vasync = require('vasync'); var common = require('../common'); var errors = require('../errors'); var mod_config = require('../config'); +var profilecommon = require('./profilecommon'); function _createProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.optionalString(opts.file, 'opts.file'); assert.optionalString(opts.copy, 'opts.copy'); + assert.optionalBool(opts.noDocker, 'opts.noDocker'); assert.func(cb, 'cb'); var cli = opts.cli; var log = cli.log; @@ -136,6 +138,8 @@ function _createProfile(opts, cb) { desc: 'The CloudAPI endpoint URL.', default: defaults.url, key: 'url' + // TODO: shortcut to allow 'ssh nightly1' to have this ssh + // in and find cloudapi for me }, { desc: 'Your account login name.', key: 'account', @@ -186,6 +190,19 @@ function _createProfile(opts, cb) { return valCb(keyErr); } + /* + * Save the user's explicit given key path. We will + * using it later for Docker setup. Trying to use + * the same format as node-smartdc's loadSSHKey + * `keyPaths` param. + */ + ctx.keyPaths = {}; + if (keyType === 'ssh') { + ctx.keyPaths.public = keyPath; + } else { + ctx.keyPaths.private = keyPath; + } + var newVal = key.fingerprint('md5').toString(); console.log('Fingerprint: %s', newVal); valCb(null, newVal); @@ -259,6 +276,21 @@ function _createProfile(opts, cb) { } next(); }, + + function dockerSetup(ctx, next) { + if (opts.noDocker || process.platform === 'win32') { + next(); + return; + } + + profilecommon.profileDockerSetup({ + cli: cli, + name: data.name, + keyPaths: ctx.keyPaths, + implicit: true + }, next); + }, + function setCurrIfTheOnlyProfile(ctx, next) { if (ctx.profiles.length !== 0) { next(); @@ -276,7 +308,7 @@ function _createProfile(opts, cb) { return; } console.log('Set "%s" as current profile (because it is ' + - 'your only profile)', data.name); + 'your only profile).', data.name); next(); }); } @@ -299,7 +331,8 @@ function do_create(subcmd, opts, args, cb) { _createProfile({ cli: this.top, file: opts.file, - copy: opts.copy + copy: opts.copy, + noDocker: opts.no_docker }, cb); } @@ -321,6 +354,14 @@ do_create.options = [ type: 'string', helpArg: 'NAME', help: 'A profile from which to copy values.' + }, + { + names: ['no-docker'], + type: 'bool', + help: 'As of Triton CLI 4.9, creating a profile will attempt (on ' + + 'non-Windows) to also setup for running Docker. This is ' + + 'experimental and might fail. Use this option to disable ' + + 'the attempt.' } ]; diff --git a/lib/do_profile/do_docker_setup.js b/lib/do_profile/do_docker_setup.js new file mode 100644 index 0000000..0dedb7c --- /dev/null +++ b/lib/do_profile/do_docker_setup.js @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Joyent Inc. + * + * `triton profile docker-setup ...` + */ + +var assert = require('assert-plus'); + +var errors = require('../errors'); +var profilecommon = require('./profilecommon'); + + +function do_docker_setup(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 arguments')); + } + + var profileName = args[0] || this.top.tritonapi.profile.name; + profilecommon.profileDockerSetup({ + cli: this.top, + name: profileName + }, cb); +} + +do_docker_setup.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +]; + +do_docker_setup.help = [ + /* BEGIN JSSTYLED */ + 'Setup for using Docker with the current Triton CLI profile.', + '', + 'Usage:', + ' {{name}} docker-setup [PROFILE]', + '', + '{{options}}', + 'A Triton datacenter can act as a virtual Docker Engine, where the entire', + 'datacenter is available on for running containers. The datacenter provides', + 'an endpoint against which you can run the regular `docker` client. This', + 'requires a one time setup to (a) generate a client TLS certificate to enable', + 'secure authentication with the Triton Docker Engine, and (b) to determine', + 'the DOCKER_HOST and related environment variables.', + '', + 'After running this, you can setup your shell environment for `docker` via:', + ' eval "$(triton env --docker)"', + 'or the equivalent. See `triton env --help` for details.' + /* END JSSTYLED */ +].join('\n'); + + +module.exports = do_docker_setup; diff --git a/lib/do_profile/do_get.js b/lib/do_profile/do_get.js index 9e7f997..6ff59d3 100644 --- a/lib/do_profile/do_get.js +++ b/lib/do_profile/do_get.js @@ -1,20 +1,13 @@ /* - * Copyright (c) 2015 Joyent Inc. + * Copyright 2016 Joyent Inc. * * `triton profile get ...` */ var assert = require('assert-plus'); -var format = require('util').format; -var fs = require('fs'); -var strsplit = require('strsplit'); -var sshpk = require('sshpk'); -var vasync = require('vasync'); -var common = require('../common'); var errors = require('../errors'); var mod_config = require('../config'); -var profilecommon = require('./profilecommon'); function _showProfile(opts, cb) { diff --git a/lib/do_profile/do_list.js b/lib/do_profile/do_list.js index afdfd39..0c1ef59 100644 --- a/lib/do_profile/do_list.js +++ b/lib/do_profile/do_list.js @@ -78,9 +78,14 @@ function _listProfiles(cli, opts, args, cb) { sort: sort }); if (!haveCurr) { - process.stderr.write('\nWarning: There is no current profile. ' - + 'Use "triton profile set-current ..."\n' - + 'to set one or "triton profile create" to create one.\n'); + if (profiles.length === 0) { + process.stderr.write('\nWarning: There is no current profile. ' + + 'Use "triton profile create" to create one.\n'); + } else { + process.stderr.write('\nWarning: There is no current profile. ' + + 'Use "triton profile set-current ..."\n' + + 'to set one or "triton profile create" to create one.\n'); + } } } cb(); diff --git a/lib/do_profile/index.js b/lib/do_profile/index.js index 58615cc..ac08428 100644 --- a/lib/do_profile/index.js +++ b/lib/do_profile/index.js @@ -41,7 +41,8 @@ function ProfileCLI(top) { 'set-current', 'create', 'edit', - 'delete' + 'delete', + 'docker-setup' ] }); } @@ -58,6 +59,7 @@ ProfileCLI.prototype.do_set_current = require('./do_set_current'); ProfileCLI.prototype.do_create = require('./do_create'); ProfileCLI.prototype.do_delete = require('./do_delete'); ProfileCLI.prototype.do_edit = require('./do_edit'); +ProfileCLI.prototype.do_docker_setup = require('./do_docker_setup'); // TODO: Would like to `triton profile update foo account=trentm ...` // And then would like that same key=value syntax optional for create. diff --git a/lib/do_profile/profilecommon.js b/lib/do_profile/profilecommon.js index c9d1d54..ac39950 100644 --- a/lib/do_profile/profilecommon.js +++ b/lib/do_profile/profilecommon.js @@ -5,11 +5,68 @@ */ var assert = require('assert-plus'); +var auth = require('smartdc-auth'); +var format = require('util').format; +var fs = require('fs'); +var https = require('https'); +var mkdirp = require('mkdirp'); +var path = require('path'); +var rimraf = require('rimraf'); +var semver = require('semver'); +var sshpk = require('sshpk'); +var mod_url = require('url'); +var vasync = require('vasync'); +var which = require('which'); +var wordwrap = require('wordwrap')(78); +var common = require('../common'); var mod_config = require('../config'); var errors = require('../errors'); +// --- internal support functions + +function portalUrlFromCloudapiUrl(url) { + assert.string(url, 'url'); + var portalUrl; + + var JPC_RE = /^https:\/\/([a-z0-9-]+)\.api\.joyent(cloud)?\.com\/?$/; + if (JPC_RE.test(url)) { + return 'https://my.joyent.com'; + } + return portalUrl; +} + + +function downloadUrl(opts, cb) { + assert.string(opts.url, 'opts.url'); + assert.ok(/^https:/.test(opts.url)); + assert.string(opts.dest, 'opts.dest'); + assert.optionalBool(opts.insecure, 'opts.insecure'); + assert.func(cb, 'cb'); + + var reqOpts = mod_url.parse(opts.url); + if (opts.insecure) { + reqOpts.rejectUnauthorized = false; + } + + var file = fs.createWriteStream(opts.dest); + var req = https.get(reqOpts, function (res) { + res.pipe(file); + file.on('finish', function () { + file.close(cb); + }); + }); + + req.on('error', function (err) { + fs.unlink(opts.dest); + cb(err); + }); +} + + +// --- exported functions + function setCurrentProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); @@ -63,6 +120,348 @@ function setCurrentProfile(opts, cb) { } +/** + * Setup the given profile for Docker usage. This means checking the cloudapi + * has a Docker service (ListServices), finding the user's SSH *private* key, + * creating the client certs that will be used to talk to the Triton Docker + * Engine. + * + * @param {Object} opts: Required. + * - {Object} cli: Required. The Triton CLI object. + * - {String} name: Required. The profile name. + * - {Boolean} implicit: Optional. Boolean indicating if the Docker setup + * is implicit (e.g. as a default part of `triton profile create`). If + * implicit, we silently skip if ListServices shows no Docker service. + * - {Object} keyPaths: Optional. An object with `private` and/or `public` + * properties pointing to a full path to an SSH private and/or public + * key to use for cert signing. + */ +function profileDockerSetup(opts, cb) { + assert.object(opts.cli, 'opts.cli'); + assert.string(opts.name, 'opts.name'); + assert.optionalBool(opts.implicit, 'opts.implicit'); + assert.optionalObject(opts.keyPaths, 'opts.keyPaths'); + assert.func(cb, 'cb'); + + var implicit = Boolean(opts.implicit); + var cli = opts.cli; + var log = cli.log; + var tritonapi = cli.tritonapiFromProfileName({profileName: opts.name}); + var profile = tritonapi.profile; + var dockerHost; + + vasync.pipeline({arg: {}, funcs: [ + function checkCloudapiStatus(arg, next) { + tritonapi.cloudapi.ping({}, function (err, pong, res) { + if (!res) { + next(new errors.SetupError(err, format( + 'error pinging CloudAPI <%s>', profile.url))); + } else if (res.statusCode === 503) { + // TODO: Use maint res headers to estimate time back up. + next(new errors.SetupError(err, format('CloudAPI <%s> is ', + + 'in maintenance, please try again later', + profile.url))); + } else if (res.statusCode === 200) { + next(); + } else { + next(new errors.SetupError(err, format( + 'error pinging CloudAPI <%s>: %s status code', + profile.url, res.statusCode))); + } + }); + }, + + function checkForDockerService(arg, next) { + tritonapi.cloudapi.listServices({}, function (err, svcs, res) { + if (!res) { + next(new errors.SetupError(err, format( + 'could not list services on cloudapi %s', + profile.url))); + } else if (res.statusCode === 401) { + var portalUrl = portalUrlFromCloudapiUrl(profile.url); + if (portalUrl) { + next(new errors.SetupError(err, format( + 'invalid credentials. Visit <%s> to create the ' + + '"%s" account and/or add your SSH public key', + portalUrl, profile.account))); + } else { + next(new errors.SetupError(err, format( + 'invalid credentials. You must create the ' + + '"%s" account and/or add your SSH public key', + profile.account))); + } + } else if (res.statusCode === 200) { + if (svcs.docker) { + dockerHost = svcs.docker; + log.trace({dockerHost: dockerHost}, + 'profileDockerSetup: checkForDockerService'); + next(); + } else if (implicit) { + /* + * No Docker service on this CloudAPI and this is an + * implicit attempt to setup for Docker, so that's fine. + * Use the early-abort signal. + */ + next(true); + } else { + next(new errors.SetupError(err, format( + 'no "docker" service on this datacenter', + profile.url))); + } + } else { + // TODO: If this doesn't show res details, add that. + next(new errors.SetupError(err, format( + 'unexpected response from cloudapi %s: %s status code', + profile.url, res.statusCode))); + } + }); + }, + + function mentionSettingUp(arg, next) { + console.log('Setting up profile "%s" to use Docker.', profile.name); + next(); + }, + + function findSshPrivKey_keyPaths(arg, next) { + if (!opts.keyPaths) { + next(); + return; + } + + var privKeyPath = opts.keyPaths.private; + if (!privKeyPath) { + assert.string(opts.keyPaths.public); + assert.ok(opts.keyPaths.public.slice(-4) === '.pub'); + privKeyPath = opts.keyPaths.public.slice(0, -4); + if (!fs.existsSync(privKeyPath)) { + cb(new errors.SetupError(format('could not find SSH ' + + 'private key file from public key file "%s": "%s" ' + + 'does not exist', opts.keyPaths.public, + privKeyPath))); + return; + } + } + + arg.sshKeyPaths = { + private: privKeyPath, + public: opts.keyPaths.public + }; + + fs.readFile(privKeyPath, function (readErr, keyData) { + if (readErr) { + cb(readErr); + return; + } + try { + arg.sshPrivKey = sshpk.parseKey(keyData, 'pem'); + } catch (keyErr) { + cb(keyErr); + return; + } + log.trace({sshKeyPaths: arg.sshKeyPaths}, + 'profileDockerSetup: findSshPrivKey_keyPaths'); + next(); + }); + }, + function findSshPrivKey_keyId(arg, next) { + if (opts.keyPaths) { + next(); + return; + } + + // TODO: keyPaths here is using a non-#master of node-smartdc-auth. + // Change back to a smartdc-auth release when + // https://github.com/joyent/node-smartdc-auth/pull/5 is in. + auth.loadSSHKey(profile.keyId, function (err, key, keyPaths) { + if (err) { + // TODO: better error message here. + next(err); + } else { + assert.ok(key, 'key from auth.loadSSHKey'); + log.trace({keyId: profile.keyId, sshKeyPaths: keyPaths}, + 'profileDockerSetup: findSshPrivKey'); + arg.sshKeyPaths = keyPaths; + arg.sshPrivKey = key; + next(); + } + }); + }, + + /* + * Find the `docker` version, if we can. This can be used later to + * control some envvars that depend on the docker version. + */ + function whichDocker(arg, next) { + which('docker', function (err, dockerPath) { + if (err) { + console.log(wordwrap('\nNote: No "docker" was found on ' + + 'your PATH. It is not needed for this setup, but ' + + 'will be to run docker commands against Triton. ' + + 'You can find out how to install it at ' + + '.')); + } else { + arg.dockerPath = dockerPath; + log.trace({dockerPath: dockerPath}, + 'profileDockerSetup: whichDocker'); + } + next(); + }); + }, + function getDockerClientVersion(arg, next) { + if (!arg.dockerPath) { + next(); + return; + } + common.execPlus({ + cmd: arg.dockerPath + ' --version', + log: log + }, function (err, stdout, stderr) { + if (err) { + console.log( + '\nWarning: Could not determine Docker version:\n%s', + common.indent(err.toString())); + } else { + // E.g.: 'Docker version 1.9.1, build a34a1d5' + // JSSTYLED + var DOCKER_VER_RE = /^Docker version (.*?), build/; + var match = DOCKER_VER_RE.exec(stdout); + if (!match) { + console.log('\nWarning: Could not determine Docker ' + + 'version: output of `%s --version` does not ' + + 'match %s: %j', arg.dockerPath, DOCKER_VER_RE, + stdout); + } else { + arg.dockerVersion = match[1]; + log.trace({dockerVersion: arg.dockerVersion}, + 'profileDockerSetup: getDockerClientVersion'); + } + } + next(); + }); + }, + + + function genClientCert_dir(arg, next) { + arg.dockerCertPath = path.resolve(cli.configDir, + 'docker', common.profileSlug(profile)); + mkdirp(arg.dockerCertPath, next); + }, + function genClientCert_key(arg, next) { + arg.keyPath = path.resolve(arg.dockerCertPath, 'key.pem'); + common.execPlus({ + cmd: format('openssl rsa -in %s -out %s -outform pem', + arg.sshKeyPaths.private, arg.keyPath), + log: log + }, next); + }, + function genClientCert_csr(arg, next) { + arg.csrPath = path.resolve(arg.dockerCertPath, 'csr.pem'); + common.execPlus({ + cmd: format('openssl req -new -key %s -out %s -subj "/CN=%s"', + arg.keyPath, arg.csrPath, profile.account), + log: log + }, next); + }, + function genClientCert_cert(arg, next) { + arg.certPath = path.resolve(arg.dockerCertPath, 'cert.pem'); + common.execPlus({ + cmd: format( + 'openssl x509 -req -days 365 -in %s -signkey %s -out %s', + arg.csrPath, arg.keyPath, arg.certPath), + log: log + }, next); + }, + function genClientCert_deleteCsr(arg, next) { + rimraf(arg.csrPath, next); + }, + + function getServerCa(arg, next) { + arg.caPath = path.resolve(arg.dockerCertPath, 'ca.pem'); + var caUrl = dockerHost.replace(/^tcp:/, 'https:') + '/ca.pem'; + downloadUrl({ + url: caUrl, + dest: arg.caPath, + insecure: profile.insecure + }, next); + }, + + function writeSetupJson(arg, next) { + var setupJson = path.resolve(arg.dockerCertPath, 'setup.json'); + var setup = { + profile: profile, + time: (new Date()).toISOString(), + env: { + DOCKER_CERT_PATH: arg.dockerCertPath, + DOCKER_HOST: dockerHost, + DOCKER_TLS_VERIFY: '1' + } + }; + + if (profile.insecure) { + setup.env.DOCKER_TLS_VERIFY = null; // signal to unset it + } else { + setup.env.DOCKER_TLS_VERIFY = '1'; + } + + /* + * Docker version 1.9.0 was released at the same time as + * Docker Compose 1.5.0. In that version they changed from using + * DOCKER_CLIENT_TIMEOUT to COMPOSE_HTTP_TIMEOUT and, + * annoyingly, added a warning that the user sees for all + * `compose ...` runs about DOCKER_CLIENT_TIMEOUT being + * deprecated. We want to avoid that deprecation message, + * but need the timeout value. + * + * This isn't fool proof (using mismatched `docker` and + * `docker-compose` versions). It is debatable if we want to + * play this game. E.g. someone moving from Docker 1.8 to newer, + * *but not re-setting up envvars* may start hitting timeouts. + */ + if (!arg.dockerVersion) { + setup.env.DOCKER_CLIENT_TIMEOUT = '300'; + setup.env.COMPOSE_HTTP_TIMEOUT = '300'; + } else if (semver.gte(arg.dockerVersion, '1.9.0')) { + setup.env.COMPOSE_HTTP_TIMEOUT = '300'; + } else { + setup.env.DOCKER_CLIENT_TIMEOUT = '300'; + } + + fs.writeFile(setupJson, + JSON.stringify(setup, null, 4) + '\n', + next); + }, + + function mentionSuccess(arg, next) { + console.log( + 'Setup profile "%s" to use Docker%s. Try this:\n' + + ' eval "$(triton env --docker%s)"\n' + + ' docker%s info', + profile.name, + (arg.dockerVersion ? format(' (v%s)', arg.dockerVersion) : ''), + (profile.name === cli.profileName ? '' : ' ' + profile.name), + (profile.insecure ? ' --tls' : '')); + next(); + } + + ]}, function (err) { + tritonapi.close(); + + if (err === true) { // Early-abort signal. + err = null; + } + if (err && !dockerHost && implicit) { + console.error('Warning: Error determining ' + + 'if CloudAPI "%s" provides a Docker service:\n %s', + profile.url, err); + err = null; + } + + cb(err); + }); +} + + module.exports = { - setCurrentProfile: setCurrentProfile + setCurrentProfile: setCurrentProfile, + profileDockerSetup: profileDockerSetup }; diff --git a/lib/errors.js b/lib/errors.js index 586b3fa..5fccaf8 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -127,7 +127,7 @@ util.inherits(InternalError, _TritonBaseVError); /** - * CLI usage error + * Error in config or profile data. */ function ConfigError(cause, message) { if (message === undefined) { @@ -164,6 +164,26 @@ function UsageError(cause, message) { util.inherits(UsageError, _TritonBaseVError); +/** + * Error in setting up (typically in profile update/creation). + */ +function SetupError(cause, message) { + if (message === undefined) { + message = cause; + cause = undefined; + } + assert.string(message); + _TritonBaseVError.call(this, { + cause: cause, + message: message, + code: 'Setup', + exitStatus: 1 + }); +} +util.inherits(SetupError, _TritonBaseVError); + + + /** * An error signing a request. */ @@ -280,6 +300,7 @@ module.exports = { InternalError: InternalError, ConfigError: ConfigError, UsageError: UsageError, + SetupError: SetupError, SigningError: SigningError, SelfSignedCertError: SelfSignedCertError, TimeoutError: TimeoutError, diff --git a/package.json b/package.json index e62bd18..64c283b 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": "4.8.1", + "version": "4.9.0", "author": "Joyent (joyent.com)", "dependencies": { "assert-plus": "0.2.0", @@ -18,12 +18,14 @@ "restify-clients": "1.1.0", "restify-errors": "3.0.0", "rimraf": "2.4.4", + "semver": "5.1.0", + "smartdc-auth": "git+https://github.com/joyent/node-smartdc-auth.git#05d9077", "sshpk": "1.7.x", - "smartdc-auth": "2.3.1", "strsplit": "1.0.0", "tabula": "1.7.0", "vasync": "1.6.3", "verror": "1.6.0", + "which": "1.2.4", "wordwrap": "1.0.0" }, "devDependencies": {