diff --git a/CHANGES.md b/CHANGES.md index f6332c5..609b4df 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,11 @@ Known issues: ## not yet released +## 5.7.0 + +- [TRITON-116] node-triton image sharing. Adds `triton image share` and + `triton image unshare` commands. + ## 5.6.1 - [PUBAPI-1470] volume objects should expose their creation timestamp in a diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index ba43791..45924b9 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2017 Joyent, Inc. + * Copyright (c) 2018, Joyent, Inc. * * Client library for the SmartDataCenter Cloud API (cloudapi). * http://apidocs.joyent.com/cloudapi/ @@ -767,6 +767,33 @@ CloudApi.prototype.exportImage = function exportImage(opts, cb) { }); }; +/** + * Update an image. + * + * + * @param {Object} opts + * - {UUID} id Required. The id of the image to update. + * - {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) { + assert.uuid(opts.id, 'id'); + assert.object(opts.fields, 'fields'); + assert.func(cb, 'cb'); + + this._request({ + method: 'POST', + path: format('/%s/images/%s?action=update', this.account, opts.id), + data: opts.fields + }, 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/do_image/do_share.js b/lib/do_image/do_share.js new file mode 100644 index 0000000..9bc994f --- /dev/null +++ b/lib/do_image/do_share.js @@ -0,0 +1,115 @@ +/* + * 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 share ...` + */ + +var format = require('util').format; +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + +// ---- the command + +function do_share(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 2) { + cb(new errors.UsageError( + 'incorrect number of args: expect 2, got ' + args.length)); + return; + } + + var log = this.top.log; + var tritonapi = this.top.tritonapi; + + vasync.pipeline({arg: {cli: this.top}, funcs: [ + common.cliSetupTritonApi, + function shareImage(ctx, next) { + log.trace({dryRun: opts.dry_run, account: ctx.account}, + 'image share account'); + + if (opts.dry_run) { + next(); + return; + } + + tritonapi.shareImage({ + image: args[0], + account: args[1] + }, function (err, img) { + if (err) { + next(new errors.TritonError(err, 'error sharing image')); + return; + } + + log.trace({img: img}, 'image share result'); + + if (opts.json) { + console.log(JSON.stringify(img)); + } else { + console.log('Shared image %s with account %s', + args[0], args[1]); + } + + next(); + }); + } + ]}, function (err) { + cb(err); + }); +} + +do_share.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 sharing.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; + +do_share.synopses = [ + '{{name}} {{cmd}} [OPTIONS] IMAGE ACCOUNT' +]; + +do_share.help = [ + /* BEGIN JSSTYLED */ + 'Share an image with another account.', + '', + '{{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).', + '', + 'Where "ACCOUNT" is the full account UUID.', + '', + 'Note: Only images that are owned by the account can be shared.' + /* END JSSTYLED */ +].join('\n'); + +do_share.completionArgtypes = ['tritonimage', 'none']; + +module.exports = do_share; diff --git a/lib/do_image/do_unshare.js b/lib/do_image/do_unshare.js new file mode 100644 index 0000000..5675114 --- /dev/null +++ b/lib/do_image/do_unshare.js @@ -0,0 +1,115 @@ +/* + * 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 unshare ...` + */ + +var format = require('util').format; +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + +// ---- the command + +function do_unshare(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 2) { + cb(new errors.UsageError( + 'incorrect number of args: expect 2, got ' + args.length)); + return; + } + + var log = this.top.log; + var tritonapi = this.top.tritonapi; + + vasync.pipeline({arg: {cli: this.top}, funcs: [ + common.cliSetupTritonApi, + function unshareImage(ctx, next) { + log.trace({dryRun: opts.dry_run, account: ctx.account}, + 'image unshare account'); + + if (opts.dry_run) { + next(); + return; + } + + tritonapi.unshareImage({ + image: args[0], + account: args[1] + }, function (err, img) { + if (err) { + next(new errors.TritonError(err, 'error unsharing image')); + return; + } + + log.trace({img: img}, 'image unshare result'); + + if (opts.json) { + console.log(JSON.stringify(img)); + } else { + console.log('Unshared image %s with account %s', + args[0], args[1]); + } + + next(); + }); + } + ]}, function (err) { + cb(err); + }); +} + +do_unshare.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 sharing.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; + +do_unshare.synopses = [ + '{{name}} {{cmd}} [OPTIONS] IMAGE ACCOUNT' +]; + +do_unshare.help = [ + /* BEGIN JSSTYLED */ + 'Unshare an image with another account.', + '', + '{{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).', + '', + 'Where "ACCOUNT" is the full account UUID.', + '', + 'Note: Only images that are owned by the account can be unshared.' + /* END JSSTYLED */ +].join('\n'); + +do_unshare.completionArgtypes = ['tritonimage', 'none']; + +module.exports = do_unshare; diff --git a/lib/do_image/index.js b/lib/do_image/index.js index f427e6d..832cd59 100644 --- a/lib/do_image/index.js +++ b/lib/do_image/index.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2017 Joyent, Inc. + * Copyright (c) 2018, Joyent, Inc. * * `triton image ...` */ @@ -36,6 +36,8 @@ function ImageCLI(top) { 'create', 'delete', 'export', + 'share', + 'unshare', 'wait' ] }); @@ -52,6 +54,8 @@ ImageCLI.prototype.do_get = require('./do_get'); ImageCLI.prototype.do_create = require('./do_create'); ImageCLI.prototype.do_delete = require('./do_delete'); ImageCLI.prototype.do_export = require('./do_export'); +ImageCLI.prototype.do_share = require('./do_share'); +ImageCLI.prototype.do_unshare = require('./do_unshare'); ImageCLI.prototype.do_wait = require('./do_wait'); diff --git a/lib/tritonapi.js b/lib/tritonapi.js index 28e7be9..b1683cc 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2017 Joyent, Inc. + * Copyright (c) 2018, Joyent, Inc. */ /* BEGIN JSSTYLED */ @@ -233,10 +233,10 @@ function _stepPkgId(arg, next) { /** * A function appropriate for `vasync.pipeline` funcs that takes a `arg.image` - * image name, shortid, or uuid, and determines the image id (setting it - * as arg.imgId). + * image name, shortid, or uuid, and determines the image object (setting it + * as arg.img). */ -function _stepImgId(arg, next) { +function _stepImg(arg, next) { assert.object(arg.client, 'arg.client'); assert.string(arg.image, 'arg.image'); @@ -244,7 +244,7 @@ function _stepImgId(arg, next) { if (err) { next(err); } else { - arg.imgId = img.id; + arg.img = img; next(); } }); @@ -745,10 +745,10 @@ TritonApi.prototype.exportImage = function exportImage(opts, cb) }; vasync.pipeline({arg: arg, funcs: [ - _stepImgId, + _stepImg, function cloudApiExportImage(ctx, next) { self.cloudapi.exportImage({ - id: ctx.imgId, manta_path: opts.manta_path }, + id: ctx.img.id, manta_path: opts.manta_path }, function (err, exportInfo_, res_) { if (err) { next(err); @@ -769,6 +769,101 @@ TritonApi.prototype.exportImage = function exportImage(opts, cb) }); }; +/** + * Share an image with another account. + * + * @param {Object} opts + * - {String} image The image UUID, name, or short ID. Required. + * - {String} account The account UUID. Required. + * @param {Function} cb `function (err, img)` + * On failure `err` is an error instance, else it is null. + * On success: `img` is an image object. + */ +TritonApi.prototype.shareImage = function shareImage(opts, cb) +{ + var self = this; + assert.object(opts, 'opts'); + assert.string(opts.image, 'opts.image'); + assert.string(opts.account, 'opts.account'); + assert.func(cb, 'cb'); + + var arg = { + image: opts.image, + client: self + }; + var res; + + vasync.pipeline({arg: arg, funcs: [ + _stepImg, + function validateAcl(ctx, next) { + ctx.acl = ctx.img.acl && ctx.img.acl.slice() || []; + if (ctx.acl.indexOf(opts.account) === -1) { + ctx.acl.push(opts.account); + } + next(); + }, + function cloudApiShareImage(ctx, next) { + self.cloudapi.updateImage({id: ctx.img.id, fields: {acl: ctx.acl}}, + function _updateImageCb(err, img) { + res = img; + next(err); + }); + } + ]}, function (err) { + cb(err, res); + }); +}; + +/** + * Unshare an image with another account. + * + * @param {Object} opts + * - {String} image The image UUID, name, or short ID. Required. + * - {String} account The account UUID. Required. + * @param {Function} cb `function (err, img)` + * On failure `err` is an error instance, else it is null. + * On success: `img` is an image object. + */ +TritonApi.prototype.unshareImage = function unshareImage(opts, cb) +{ + var self = this; + assert.object(opts, 'opts'); + assert.string(opts.image, 'opts.image'); + assert.string(opts.account, 'opts.account'); + assert.func(cb, 'cb'); + + var arg = { + image: opts.image, + client: self + }; + var res; + + vasync.pipeline({arg: arg, funcs: [ + _stepImg, + function validateAcl(ctx, next) { + assert.object(ctx.img, 'img'); + ctx.acl = ctx.img.acl && ctx.img.acl.slice() || []; + var aclIdx = ctx.acl.indexOf(opts.account); + if (aclIdx === -1) { + cb(new errors.TritonError(format('image is not shared with %s', + opts.account))); + return; + } + ctx.acl.splice(aclIdx, 1); + next(); + }, + function cloudApiUnshareImage(ctx, next) { + self.cloudapi.updateImage({id: ctx.img.id, fields: {acl: ctx.acl}}, + function _updateImageCb(err, img) { + res = img; + next(err); + }); + } + ]}, function (err) { + cb(err, res); + }); +}; + /** * Get an active package by ID, exact name, or short ID, in that order. * diff --git a/package.json b/package.json index 0d00f3f..03cc0eb 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": "5.6.1", + "version": "5.7.0", "author": "Joyent (joyent.com)", "homepage": "https://github.com/joyent/node-triton", "dependencies": { @@ -33,7 +33,8 @@ }, "devDependencies": { "tape": "4.2.0", - "tap-summary": "3.0.2" + "tap-summary": "3.0.2", + "uuid": "3.2.1" }, "main": "./lib", "scripts": { diff --git a/test/integration/cli-image-create.test.js b/test/integration/cli-image-create.test.js index 392813f..3f63c1d 100644 --- a/test/integration/cli-image-create.test.js +++ b/test/integration/cli-image-create.test.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2016, Joyent, Inc. + * Copyright (c) 2018, Joyent, Inc. */ /* @@ -15,6 +15,7 @@ var format = require('util').format; var os = require('os'); var test = require('tape'); +var uuid = require('uuid'); var vasync = require('vasync'); var common = require('../../lib/common'); @@ -199,6 +200,62 @@ test('triton image ...', testOpts, function (tt) { } }); + tt.test(' triton image share ...', function (t) { + var dummyUuid = uuid.v4(); + var argv = ['image', 'share', img.id, dummyUuid]; + h.safeTriton(t, argv, function (err) { + if (err) { + t.end(); + return; + } + argv = ['image', 'get', '-j', img.id]; + h.safeTriton(t, argv, function (err2, stdout2) { + t.ifErr(err2, 'image get response'); + if (err2) { + t.end(); + return; + } + var result = JSON.parse(stdout2); + t.ok(result, 'image share result'); + t.ok(result.acl, 'image share result.acl'); + if (result.acl && Array.isArray(result.acl)) { + t.notEqual(result.acl.indexOf(dummyUuid), -1, + 'image share result.acl contains uuid'); + } else { + t.fail('image share result does not contain acl array'); + } + unshareImage(); + }); + }); + + function unshareImage() { + argv = ['image', 'unshare', img.id, dummyUuid]; + h.safeTriton(t, argv, function (err) { + if (err) { + t.end(); + return; + } + argv = ['image', 'get', '-j', img.id]; + h.safeTriton(t, argv, function (err2, stdout2) { + t.ifErr(err2, 'image get response'); + if (err2) { + t.end(); + return; + } + var result = JSON.parse(stdout2); + t.ok(result, 'image unshare result'); + if (result.acl && Array.isArray(result.acl)) { + t.equal(result.acl.indexOf(dummyUuid), -1, + 'image unshare result.acl should not contain uuid'); + } else { + t.equal(result.acl, undefined, 'image has no acl'); + } + t.end(); + }); + }); + } + }); + // TODO: Once have `triton ssh ...` working in test suite without hangs, // then want to check that the created VM has the markerFile.