diff --git a/CHANGES.md b/CHANGES.md index 194bddc..e44c01b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,8 @@ Known issues: ## not yet released +## 6.1.0 + - [joyent/node-triton#250] Avoid an error from `triton profile list` if only *some* of the minimal `TRITON_` or `SDC_` envvars are defined. - [TRITON-401] Add `triton network` and `triton vlan` commands, for @@ -17,6 +19,13 @@ Known issues: Docker setup and signs them with an account key, rather than copying (and decrypting) the account key itself. This makes using Docker simpler with keys in an SSH Agent. +- [TRITON-53] x-account image clone. A user can make a copy of a shared image + using the `triton image clone` command. +- [TRITON-53] A shared image (i.e. when the user is on the image.acl) is no + longer provisionable by default - you will need to explicitly add the + --allow-shared-images cli option when calling `triton create` command to + provision from a shared image (or clone the image then provision from the + clone). ## 6.0.0 diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 3deeefd..f98a773 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -1039,7 +1039,7 @@ CloudApi.prototype.exportImage = function exportImage(opts, cb) { * - {Object} fields Required. The fields to update in the image. * @param {Function} cb of the form `function (err, body, res)` */ -CloudApi.prototype.updateImage = function shareImage(opts, cb) { +CloudApi.prototype.updateImage = function updateImage(opts, cb) { assert.uuid(opts.id, 'id'); assert.object(opts.fields, 'fields'); assert.func(cb, 'cb'); @@ -1057,6 +1057,31 @@ CloudApi.prototype.updateImage = function shareImage(opts, cb) { }); }; +/** + * Clone an image. + * + * + * @param {Object} opts + * - {UUID} id Required. The id of the image to update. + * @param {Function} cb of the form `function (err, body, res)` + */ +CloudApi.prototype.cloneImage = function cloneImage(opts, cb) { + assert.uuid(opts.id, 'id'); + assert.func(cb, 'cb'); + + this._request({ + method: 'POST', + path: format('/%s/images/%s?action=clone', this.account, opts.id), + data: {} + }, function (err, req, res, body) { + if (err) { + cb(err, null, res); + return; + } + cb(null, body, res); + }); +}; + /** * Wait for an image to go one of a set of specfic states. * diff --git a/lib/common.js b/lib/common.js index 613732e..73051e3 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1461,6 +1461,19 @@ function parseNicStr(nic) { return obj; } +/* + * Return a short image string that represents the given image object. + * + * @param img {Object} The image object. + * @returns {String} A network object. E.g. + * 'a6cf222d-73f4-414c-a427-5c238ef8e1b7 (jillmin@1.0.0)' + */ +function imageRepr(img) { + assert.object(img); + + return format('%s (%s@%s)', img.id, img.name, img.version); +} + //---- exports @@ -1502,6 +1515,7 @@ module.exports = { readStdin: readStdin, validateObject: validateObject, ipv4ToLong: ipv4ToLong, - parseNicStr: parseNicStr + parseNicStr: parseNicStr, + imageRepr: imageRepr }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_image/do_clone.js b/lib/do_image/do_clone.js new file mode 100644 index 0000000..814a570 --- /dev/null +++ b/lib/do_image/do_clone.js @@ -0,0 +1,107 @@ +/* + * 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) 2018, Joyent, Inc. + * + * `triton image clone ...` + */ + +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + +// ---- the command + +function do_clone(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 1) { + cb(new errors.UsageError( + 'incorrect number of args: expected 1, got ' + args.length)); + return; + } + + var log = this.top.log; + var tritonapi = this.top.tritonapi; + + vasync.pipeline({arg: {cli: this.top}, funcs: [ + common.cliSetupTritonApi, + function cloneImage(ctx, next) { + log.trace({dryRun: opts.dry_run, account: ctx.account}, + 'image clone account'); + + if (opts.dry_run) { + next(); + return; + } + + tritonapi.cloneImage({image: args[0]}, function _cloneCb(err, img) { + if (err) { + next(new errors.TritonError(err, 'error cloning image')); + return; + } + + log.trace({img: img}, 'image clone result'); + + if (opts.json) { + console.log(JSON.stringify(img)); + } else { + console.log('Cloned image %s to %s', + args[0], common.imageRepr(img)); + } + + next(); + }); + } + ]}, cb); +} + +do_clone.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + group: 'Other options' + }, + { + names: ['dry-run'], + type: 'bool', + help: 'Go through the motions without actually cloning.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; + +do_clone.synopses = [ + '{{name}} {{cmd}} [OPTIONS] IMAGE' +]; + +do_clone.help = [ + /* BEGIN JSSTYLED */ + 'Clone a shared image.', + '', + '{{usage}}', + '', + '{{options}}', + 'Where "IMAGE" is an image id (a full UUID), an image name (selects the', + 'latest, by "published_at", image with that name), an image "name@version"', + '(selects latest match by "published_at"), or an image short ID (ID prefix).', + '', + 'Note: Only shared images can be cloned.' + /* END JSSTYLED */ +].join('\n'); + +do_clone.completionArgtypes = ['tritonimage', 'none']; + +module.exports = do_clone; diff --git a/lib/do_image/do_list.js b/lib/do_image/do_list.js index 5ef8063..22eca82 100644 --- a/lib/do_image/do_list.js +++ b/lib/do_image/do_list.js @@ -5,13 +5,15 @@ */ /* - * Copyright 2016 Joyent, Inc. + * Copyright 2018 Joyent, Inc. * * `triton image list ...` */ +var assert = require('assert-plus'); var format = require('util').format; var tabula = require('tabula'); +var vasync = require('vasync'); var common = require('../common'); var errors = require('../errors'); @@ -67,17 +69,45 @@ function do_list(subcmd, opts, args, callback) { listOpts.state = 'all'; } + var self = this; var tritonapi = this.top.tritonapi; - common.cliSetupTritonApi({cli: this.top}, function onSetup(setupErr) { - if (setupErr) { - callback(setupErr); - return; - } - tritonapi.listImages(listOpts, function onRes(err, imgs, res) { - if (err) { - return callback(err); - } + vasync.pipeline({ arg: {}, funcs: [ + function setupTritonApi(_, next) { + common.cliSetupTritonApi({cli: self.top}, next); + }, + function getImages(ctx, next) { + tritonapi.listImages(listOpts, function onRes(err, imgs, res) { + if (err) { + next(err); + return; + } + ctx.imgs = imgs; + next(); + }); + }, + function getUserAccount(ctx, next) { + // If using json output, or when there are no images that use an ACL + // - we don't need to fetch the account, as the account is only used + // to check if the image is shared (i.e. the account is in the image + // ACL) so it can output image flags in non-json mode. + if (opts.json || ctx.imgs.every(function _checkAcl(img) { + return !Array.isArray(img.acl) || img.acl.length === 0; + })) { + next(); + return; + } + tritonapi.cloudapi.getAccount(function _accountCb(err, account) { + if (err) { + next(err); + return; + } + ctx.account = account; + next(); + }); + }, + function formatImages(ctx, next) { + var imgs = ctx.imgs; if (opts.json) { common.jsonStream(imgs); } else { @@ -99,6 +129,20 @@ function do_list(subcmd, opts, args, callback) { if (img.origin) flags.push('I'); if (img['public']) flags.push('P'); if (img.state !== 'active') flags.push('X'); + + // Add image sharing flags. + if (Array.isArray(img.acl) && img.acl.length > 0) { + assert.string(ctx.account, 'ctx.account'); + if (img.owner === ctx.account.id) { + // This image has been shared with other accounts. + flags.push('+'); + } + if (img.acl.indexOf(ctx.account.id) !== -1) { + // This image has been shared with this account. + flags.push('S'); + } + } + img.flags = flags.length ? flags.join('') : undefined; } @@ -108,9 +152,9 @@ function do_list(subcmd, opts, args, callback) { sort: sort }); } - callback(); - }); - }); + next(); + } + ]}, callback); } do_list.options = [ @@ -157,6 +201,8 @@ do_list.help = [ ' shortid* A short ID prefix.', ' flags* Single letter flags summarizing some fields:', ' "P" image is public', + ' "+" you are sharing this image with others', + ' "S" this image has been shared with you', ' "I" an incremental image (i.e. has an origin)', ' "X" has a state *other* than "active"', ' pubdate* Short form of "published_at" with just the date', diff --git a/lib/do_image/index.js b/lib/do_image/index.js index 832cd59..e719037 100644 --- a/lib/do_image/index.js +++ b/lib/do_image/index.js @@ -33,6 +33,7 @@ function ImageCLI(top) { 'help', 'list', 'get', + 'clone', 'create', 'delete', 'export', @@ -51,6 +52,7 @@ ImageCLI.prototype.init = function init(opts, args, cb) { ImageCLI.prototype.do_list = require('./do_list'); ImageCLI.prototype.do_get = require('./do_get'); +ImageCLI.prototype.do_clone = require('./do_clone'); ImageCLI.prototype.do_create = require('./do_create'); ImageCLI.prototype.do_delete = require('./do_delete'); ImageCLI.prototype.do_export = require('./do_export'); diff --git a/lib/do_instance/do_create.js b/lib/do_instance/do_create.js index 7bc5291..cbedb35 100644 --- a/lib/do_instance/do_create.js +++ b/lib/do_instance/do_create.js @@ -289,6 +289,9 @@ function do_create(subcmd, opts, args, cb) { createOpts['tag.'+key] = ctx.tags[key]; }); } + if (opts.allow_shared_images) { + createOpts.allow_shared_images = true; + } for (var i = 0; i < opts._order.length; i++) { var opt = opts._order[i]; @@ -498,6 +501,11 @@ do_create.options = [ 'Joyent-provided images, the user-script is run at every boot ' + 'of the instance. This is a shortcut for `-M user-script=FILE`.' }, + { + names: ['allow-shared-images'], + type: 'bool', + help: 'Allow instance creation to use a shared image.' + }, { group: 'Other options' diff --git a/lib/tritonapi.js b/lib/tritonapi.js index b76aab1..f32e330 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -133,7 +133,7 @@ var errors = require('./errors'); // ---- globals -var CLOUDAPI_ACCEPT_VERSION = '~8'; +var CLOUDAPI_ACCEPT_VERSION = '~9||~8'; @@ -958,6 +958,42 @@ TritonApi.prototype.unshareImage = function unshareImage(opts, cb) }); }; +/** + * Clone a shared image. + * + * @param {Object} opts + * - {String} image The image UUID, name, or short ID. Required. + * @param {Function} cb `function (err, img)` + * On failure `err` is an error instance, else it is null. + * On success: `img` is the cloned image object. + */ +TritonApi.prototype.cloneImage = function cloneImage(opts, cb) +{ + var self = this; + assert.object(opts, 'opts'); + assert.string(opts.image, 'opts.image'); + assert.func(cb, 'cb'); + + var arg = { + image: opts.image, + client: self + }; + var img; + + vasync.pipeline({arg: arg, funcs: [ + _stepImg, + function cloudApiCloneImage(ctx, next) { + self.cloudapi.cloneImage({id: ctx.img.id}, + function _cloneImageCb(err, img_) { + img = img_; + next(err); + }); + } + ]}, function (err) { + cb(err, img); + }); +}; + /** * Get an active package by ID, exact name, or short ID, in that order. * diff --git a/package.json b/package.json index e0da727..2c7354f 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": "6.0.0", + "version": "6.1.0", "author": "Joyent (joyent.com)", "homepage": "https://github.com/joyent/node-triton", "dependencies": {