From bf64899685c591dce71ad17b9c2d4f03c458ec54 Mon Sep 17 00:00:00 2001 From: Mike Zeller Date: Mon, 5 Mar 2018 22:04:36 +0000 Subject: [PATCH] TRITON-58 node-triton should support nic operations Reviewed by: Marsell Kukuljevic Approved by: Marsell Kukuljevic --- lib/cloudapi2.js | 152 +++++++++++++- lib/common.js | 57 ++++- lib/do_instance/do_nic/do_create.js | 211 +++++++++++++++++++ lib/do_instance/do_nic/do_delete.js | 126 ++++++++++++ lib/do_instance/do_nic/do_get.js | 89 ++++++++ lib/do_instance/do_nic/do_list.js | 154 ++++++++++++++ lib/do_instance/do_nic/index.js | 50 +++++ lib/do_instance/index.js | 2 + lib/tritonapi.js | 224 ++++++++++++++++++++ test/integration/api-nics.test.js | 110 ++++++++++ test/integration/cli-nics.test.js | 252 +++++++++++++++++++++++ test/integration/cli-subcommands.test.js | 4 + 12 files changed, 1427 insertions(+), 4 deletions(-) create mode 100644 lib/do_instance/do_nic/do_create.js create mode 100644 lib/do_instance/do_nic/do_delete.js create mode 100644 lib/do_instance/do_nic/do_get.js create mode 100644 lib/do_instance/do_nic/do_list.js create mode 100644 lib/do_instance/do_nic/index.js create mode 100644 test/integration/api-nics.test.js create mode 100644 test/integration/cli-nics.test.js diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 45924b9..5412cd5 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -154,6 +154,12 @@ function CloudApi(options) { this.client = new SaferJsonClient(options); } +// -> +CloudApi.prototype.NETWORK_OBJECT_FIELDS = { + ipv4_uuid: 'string', + ipv4_ips: 'string' +}; + CloudApi.prototype.close = function close(callback) { this.log.trace({host: this.client.url && this.client.url.host}, @@ -1150,7 +1156,7 @@ CloudApi.prototype.createMachine = function createMachine(options, callback) { assert.optionalString(options.name, 'options.name'); assert.uuid(options.image, 'options.image'); assert.uuid(options.package, 'options.package'); - assert.optionalArrayOfUuid(options.networks, 'options.networks'); + assert.optionalArray(options.networks, 'options.networks'); // TODO: assert the other fields assert.func(callback, 'callback'); @@ -1536,6 +1542,150 @@ function deleteMachineSnapshot(opts, cb) { }; +// --- NICs + +/** + * Adds a NIC on a network to an instance. + * + * @param {Object} options object containing: + * - {String} id (required) the instance id. + * - {String|Object} (required) network uuid or network object. + * @param {Function} callback of the form f(err, nic, res). + */ +CloudApi.prototype.addNic = +function addNic(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.ok(opts.network, 'opts.network'); + + var data = { + network: opts.network + }; + + this._request({ + method: 'POST', + path: format('/%s/machines/%s/nics', this.account, opts.id), + data: data + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + +/** + * Lists all NICs on an instance. + * + * Returns an array of objects. + * + * @param opts {Object} Options + * - {String} id (required) the instance id. + * @param {Function} callback of the form f(err, nics, res). + */ +CloudApi.prototype.listNics = +function listNics(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + var endpoint = format('/%s/machines/%s/nics', this.account, opts.id); + this._passThrough(endpoint, opts, cb); +}; + + +/** + * Retrieves a NIC on an instance. + * + * @param {Object} options object containing: + * - {UUID} id: The instance id. Required. + * - {String} mac: The NIC's MAC. Required. + * @param {Function} callback of the form `function (err, nic, res)` + */ +CloudApi.prototype.getNic = +function getNic(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.string(opts.mac, 'opts.mac'); + assert.func(cb, 'cb'); + + var mac = opts.mac.replace(/:/g, ''); + var endpoint = format('/%s/machines/%s/nics/%s', this.account, opts.id, + mac); + this._request(endpoint, function (err, req, res, body) { + cb(err, body, res); + }); +}; + + +/** + * Remove a NIC off an instance. + * + * @param {Object} opts (object) + * - {UUID} id: The instance id. Required. + * - {String} mac: The NIC's MAC. Required. + * @param {Function} cb of the form `function (err, res)` + */ +CloudApi.prototype.removeNic = +function removeNic(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.string(opts.mac, 'opts.mac'); + assert.func(cb, 'cb'); + + var mac = opts.mac.replace(/:/g, ''); + + this._request({ + method: 'DELETE', + path: format('/%s/machines/%s/nics/%s', this.account, opts.id, mac) + }, function (err, req, res) { + cb(err, res); + }); +}; + + +/** + * Wait for a machine's nic to go one of a set of specfic states. + * + * @param {Object} options + * - {String} id {required} machine id + * - {String} mac {required} mac for new nic + * - {Array of String} states - desired state + * - {Number} interval (optional) - time in ms to poll + * @param {Function} callback of the form f(err, nic, res). + */ +CloudApi.prototype.waitForNicStates = +function waitForNicStates(opts, cb) { + var self = this; + + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.string(opts.mac, 'opts.mac'); + assert.arrayOfString(opts.states, 'opts.states'); + assert.optionalNumber(opts.interval, 'opts.interval'); + assert.func(cb, 'cb'); + + var interval = opts.interval || 1000; + assert.ok(interval > 0, 'interval must be a positive number'); + + poll(); + + function poll() { + self.getNic({ + id: opts.id, + mac: opts.mac + }, function onPoll(err, nic, res) { + if (err) { + cb(err, null, res); + return; + } + if (opts.states.indexOf(nic.state) !== -1) { + cb(null, nic, res); + return; + } + setTimeout(poll, interval); + }); + } +}; + + // --- firewall rules /** diff --git a/lib/common.js b/lib/common.js index 1dfb7b0..4911fd4 100644 --- a/lib/common.js +++ b/lib/common.js @@ -5,7 +5,7 @@ */ /* - * Copyright (c) 2017, Joyent, Inc. + * Copyright (c) 2018, Joyent, Inc. */ var assert = require('assert-plus'); @@ -24,7 +24,8 @@ var wordwrap = require('wordwrap'); var errors = require('./errors'), InternalError = errors.InternalError; - +var NETWORK_OBJECT_FIELDS = + require('./cloudapi2').CloudApi.prototype.NETWORK_OBJECT_FIELDS; // ---- support stuff @@ -1412,6 +1413,55 @@ function ipv4ToLong(ip) { return l; } +/* + * Parse the input from the `--nics ` CLI argument. + * + * @param a {Array} The array of strings formatted as key=value + * ex: ['ipv4_uuid=1234', 'ipv4_ips=1.2.3.4|5.6.7.8'] + * @return {Object} A network object. From the example above: + * { + * "ipv4_uuid": 1234, + * "ipv4_ips": [ + * "1.2.3.4", + * "5.6.7.8" + * ] + * } + * Note: "1234" is used as the UUID for this example, but would actually cause + * `parseNicsCLI` to throw as it is not a valid UUID. + */ +function parseNicsCLI(nic) { + assert.arrayOfString(nic); + + var obj = objFromKeyValueArgs(nic, { + disableDotted: true, + typeHintFromKey: NETWORK_OBJECT_FIELDS, + validKeys: Object.keys(NETWORK_OBJECT_FIELDS) + }); + + if (!obj.ipv4_uuid) { + throw new errors.UsageError( + 'ipv4_uuid must be specified in network object'); + } + + if (obj.ipv4_ips) { + obj.ipv4_ips = obj.ipv4_ips.split('|'); + } + + assert.uuid(obj.ipv4_uuid, 'obj.ipv4_uuid'); + assert.optionalArrayOfString(obj.ipv4_ips, 'obj.ipv4_ips'); + + /* + * Only 1 IP address may be specified at this time. In the future, this + * limitation should be removed. + */ + if (obj.ipv4_ips && obj.ipv4_ips.length !== 1) { + throw new errors.UsageError('only 1 ipv4_ip may be specified'); + } + + return obj; +} + + //---- exports module.exports = { @@ -1451,6 +1501,7 @@ module.exports = { monotonicTimeDiffMs: monotonicTimeDiffMs, readStdin: readStdin, validateObject: validateObject, - ipv4ToLong: ipv4ToLong + ipv4ToLong: ipv4ToLong, + parseNicsCLI: parseNicsCLI }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_instance/do_nic/do_create.js b/lib/do_instance/do_nic/do_create.js new file mode 100644 index 0000000..47a46ae --- /dev/null +++ b/lib/do_instance/do_nic/do_create.js @@ -0,0 +1,211 @@ +/* + * 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 2018 Joyent, Inc. + * + * `triton instance nic create ...` + */ + +var assert = require('assert-plus'); + +var common = require('../../common'); +var errors = require('../../errors'); + +function do_create(subcmd, opts, args, cb) { + assert.optionalBool(opts.wait, 'opts.wait'); + assert.optionalBool(opts.json, 'opts.json'); + assert.optionalBool(opts.help, 'opts.help'); + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length < 2) { + cb(new errors.UsageError('missing INST and NETWORK or INST and' + + ' NICOPT=VALUE arguments')); + return; + } + + var cli = this.top; + + var netObj; + var netObjArgs = []; + var regularArgs = []; + var createOpts = {}; + + args.forEach(function forEachArg(arg) { + if (arg.indexOf('=') !== -1) { + netObjArgs.push(arg); + return; + } + regularArgs.push(arg); + }); + + if (netObjArgs.length > 0) { + if (regularArgs.length > 1) { + cb(new errors.UsageError('cannot specify INST and NETWORK when' + + ' passing in ipv4 arguments')); + return; + } + if (regularArgs.length !== 1) { + cb(new errors.UsageError('missing INST argument')); + return; + } + + try { + netObj = common.parseNicsCLI(netObjArgs); + } catch (err) { + cb(err); + return; + } + } + + if (netObj) { + assert.array(regularArgs, 'regularArgs'); + assert.equal(regularArgs.length, 1, 'instance uuid'); + + createOpts.id = regularArgs[0]; + createOpts.network = netObj; + } else { + assert.array(args, 'args'); + assert.equal(args.length, 2, 'INST and NETWORK'); + + createOpts.id = args[0]; + createOpts.network = args[1]; + } + + function wait(instId, mac, next) { + assert.string(instId, 'instId'); + assert.string(mac, 'mac'); + assert.func(next, 'next'); + + var waiter = cli.tritonapi.waitForNicStates.bind(cli.tritonapi); + + /* + * We request state running|stopped because net-agent is doing work to + * keep a NICs state in sync with the VMs state. If a user adds a NIC + * to a stopped instance the final state of the NIC should also be + * stopped. + */ + waiter({ + id: instId, + mac: mac, + states: ['running', 'stopped'] + }, next); + } + + // same signature as wait(), but is a nop + function waitNop(instId, mac, next) { + assert.string(instId, 'instId'); + assert.string(mac, 'mac'); + assert.func(next, 'next'); + + next(); + } + + common.cliSetupTritonApi({cli: cli}, function onSetup(setupErr) { + if (setupErr) { + cb(setupErr); + return; + } + + cli.tritonapi.addNic(createOpts, function onAddNic(err, nic) { + if (err) { + cb(err); + return; + } + + // If a NIC exists on the network already we will receive a 302 + if (!nic) { + var errMsg = 'Instance already has a NIC on that network'; + cb(new errors.TritonError(errMsg)); + return; + } + + // either wait or invoke a nop stub + var func = opts.wait ? wait : waitNop; + + if (opts.wait && !opts.json) { + console.log('Creating NIC %s', nic.mac); + } + + func(createOpts.id, nic.mac, function onWait(err2, createdNic) { + if (err2) { + cb(err2); + return; + } + + var nicInfo = createdNic || nic; + + if (opts.json) { + console.log(JSON.stringify(nicInfo)); + } else { + console.log('Created NIC %s', nic.mac); + } + + cb(); + }); + }); + }); +} + + +do_create.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for the creation to complete.' + } +]; + +do_create.synopses = [ + '{{name}} {{cmd}} [OPTIONS] INST NETWORK', + '{{name}} {{cmd}} [OPTIONS] INST NICOPT=VALUE [NICOPT=VALUE ...]' +]; + +do_create.help = [ + 'Create a NIC.', + '', + '{{usage}}', + '', + '{{options}}', + 'INST is an instance id (full UUID), name, or short id,', + 'and NETWORK is a network id (full UUID), name, or short id.', + '', + 'NICOPTs are NIC options. The following NIC options are supported:', + 'ipv4_uuid= (required),' + + ' and ipv4_ips=.', + '', + 'Be aware that adding NICs to an instance will cause that instance to', + 'reboot.', + '', + 'Example:', + ' triton instance nic create --wait 22b75576 ca8aefb9', + ' triton instance nic create 22b75576' + + ' ipv4_uuid=651446a8-dab0-439e-a2c4-2c841ab07c51' + + ' ipv4_ips=192.168.128.13' +].join('\n'); + +do_create.helpOpts = { + helpCol: 25 +}; + +do_create.completionArgtypes = ['tritoninstance', 'tritonnic', 'none']; + +module.exports = do_create; diff --git a/lib/do_instance/do_nic/do_delete.js b/lib/do_instance/do_nic/do_delete.js new file mode 100644 index 0000000..eaf40d8 --- /dev/null +++ b/lib/do_instance/do_nic/do_delete.js @@ -0,0 +1,126 @@ +/* + * 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 2018 Joyent, Inc. + * + * `triton instance nic delete ...` + */ + +var assert = require('assert-plus'); +var vasync = require('vasync'); + +var common = require('../../common'); +var errors = require('../../errors'); + + +function do_delete(subcmd, opts, args, cb) { + assert.object(opts, 'opts'); + assert.optionalBool(opts.force, 'opts.force'); + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length < 2) { + cb(new errors.UsageError('missing INST and MAC argument(s)')); + return; + } else if (args.length > 2) { + cb(new errors.UsageError('incorrect number of arguments')); + return; + } + + var inst = args[0]; + var mac = args[1]; + var cli = this.top; + + common.cliSetupTritonApi({cli: cli}, function onSetup(setupErr) { + if (setupErr) { + cb(setupErr); + return; + } + + confirm({mac: mac, force: opts.force}, function onConfirm(confirmErr) { + if (confirmErr) { + console.error('Aborting'); + cb(); + return; + } + + cli.tritonapi.removeNic({ + id: inst, + mac: mac + }, function onRemove(err) { + if (err) { + cb(err); + return; + } + + console.log('Deleted NIC %s', mac); + cb(); + }); + }); + }); +} + + +// Request confirmation before deleting, unless --force flag given. +// If user declines, terminate early. +function confirm(opts, cb) { + assert.object(opts, 'opts'); + assert.func(cb, 'cb'); + + if (opts.force) { + cb(); + return; + } + + common.promptYesNo({ + msg: 'Delete NIC "' + opts.mac + '"? [y/n] ' + }, function (answer) { + if (answer !== 'y') { + cb(new Error('Aborted NIC deletion')); + } else { + cb(); + } + }); +} + + +do_delete.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['force', 'f'], + type: 'bool', + help: 'Force removal.' + } +]; + +do_delete.synopses = ['{{name}} {{cmd}} INST MAC']; + +do_delete.help = [ + 'Remove a NIC from an instance.', + '', + '{{usage}}', + '', + '{{options}}', + 'Where INST is an instance id (full UUID), name, or short id.', + '', + 'Be aware that removing NICs from an instance will cause that instance to', + 'reboot.' +].join('\n'); + +do_delete.aliases = ['rm']; + +do_delete.completionArgtypes = ['tritoninstance', 'none']; + +module.exports = do_delete; diff --git a/lib/do_instance/do_nic/do_get.js b/lib/do_instance/do_nic/do_get.js new file mode 100644 index 0000000..fb5612f --- /dev/null +++ b/lib/do_instance/do_nic/do_get.js @@ -0,0 +1,89 @@ +/* + * 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 2018 Joyent, Inc. + * + * `triton instance nic get ...` + */ + +var assert = require('assert-plus'); + +var common = require('../../common'); +var errors = require('../../errors'); + + +function do_get(subcmd, opts, args, cb) { + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length < 2) { + cb(new errors.UsageError('missing INST and MAC arguments')); + return; + } else if (args.length > 2) { + cb(new errors.UsageError('incorrect number of arguments')); + return; + } + + var inst = args[0]; + var mac = args[1]; + var cli = this.top; + + common.cliSetupTritonApi({cli: cli}, function onSetup(setupErr) { + if (setupErr) { + cb(setupErr); + return; + } + + cli.tritonapi.getNic({id: inst, mac: mac}, function onNic(err, nic) { + if (err) { + cb(err); + return; + } + + if (opts.json) { + console.log(JSON.stringify(nic)); + } else { + console.log(JSON.stringify(nic, null, 4)); + } + + cb(); + }); + }); +} + + +do_get.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + } +]; + +do_get.synopses = ['{{name}} {{cmd}} INST MAC']; + +do_get.help = [ + 'Show a specific NIC.', + '', + '{{usage}}', + '', + '{{options}}', + 'Where INST is an instance id (full UUID), name, or short id.' +].join('\n'); + +do_get.completionArgtypes = ['tritoninstance', 'none']; + +module.exports = do_get; diff --git a/lib/do_instance/do_nic/do_list.js b/lib/do_instance/do_nic/do_list.js new file mode 100644 index 0000000..51654ab --- /dev/null +++ b/lib/do_instance/do_nic/do_list.js @@ -0,0 +1,154 @@ +/* + * 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 2018 Joyent, Inc. + * + * `triton instance nic list ...` + */ + +var assert = require('assert-plus'); +var tabula = require('tabula'); + +var common = require('../../common'); +var errors = require('../../errors'); + + +var VALID_FILTERS = ['ip', 'mac', 'state', 'network', 'primary', 'gateway']; +var COLUMNS_DEFAULT = 'ip,mac,state,network'; +var COLUMNS_DEFAULT_LONG = 'ip,mac,state,network,primary,gateway'; +var SORT_DEFAULT = 'ip'; + + +function do_list(subcmd, opts, args, cb) { + assert.array(args, 'args'); + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length < 1) { + cb(new errors.UsageError('missing INST argument')); + return; + } + + var inst = args.shift(); + + try { + var filters = common.objFromKeyValueArgs(args, { + validKeys: VALID_FILTERS, + disableDotted: true + }); + } catch (e) { + cb(e); + return; + } + + var cli = this.top; + + common.cliSetupTritonApi({cli: cli}, function onSetup(setupErr) { + if (setupErr) { + cb(setupErr); + return; + } + + cli.tritonapi.listNics({id: inst}, function onNics(err, nics) { + if (err) { + cb(err); + return; + } + + // do filtering + Object.keys(filters).forEach(function filterByKey(key) { + var val = filters[key]; + nics = nics.filter(function filterByNic(nic) { + return nic[key] === val; + }); + }); + + if (opts.json) { + common.jsonStream(nics); + } else { + nics.forEach(function onNic(nic) { + nic.network = nic.network.split('-')[0]; + nic.ip = nic.ip + '/' + convertCidrSuffix(nic.netmask); + }); + + var columns = COLUMNS_DEFAULT; + + if (opts.o) { + columns = opts.o; + } else if (opts.long) { + columns = COLUMNS_DEFAULT_LONG; + } + + columns = columns.split(','); + var sort = opts.s.split(','); + + tabula(nics, { + skipHeader: opts.H, + columns: columns, + sort: sort + }); + } + + cb(); + }); + }); +} + + +function convertCidrSuffix(netmask) { + var bitmask = netmask.split('.').map(function (octet) { + return (+octet).toString(2); + }).join(''); + + var i = 0; + for (i = 0; i < bitmask.length; i++) { + if (bitmask[i] === '0') + break; + } + + return i; +} + + +do_list.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +].concat(common.getCliTableOptions({ + includeLong: true, + sortDefault: SORT_DEFAULT +})); + +do_list.synopses = ['{{name}} {{cmd}} [OPTIONS] [FILTERS]']; + +do_list.help = [ + 'Show all NICs on an instance.', + '', + '{{usage}}', + '', + '{{options}}', + '', + 'Where INST is an instance id (full UUID), name, or short id.', + '', + 'Filters:', + ' FIELD= String filter. Supported fields: ip, mac, state,', + ' network, netmask', + '', + 'Filters are applied client-side (i.e. done by the triton command itself).' +].join('\n'); + +do_list.completionArgtypes = ['tritoninstance', 'none']; + +do_list.aliases = ['ls']; + +module.exports = do_list; diff --git a/lib/do_instance/do_nic/index.js b/lib/do_instance/do_nic/index.js new file mode 100644 index 0000000..5110e86 --- /dev/null +++ b/lib/do_instance/do_nic/index.js @@ -0,0 +1,50 @@ +/* + * 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 2018 Joyent, Inc. + * + * `triton inst nic ...` + */ + +var Cmdln = require('cmdln').Cmdln; +var util = require('util'); + + + +// ---- CLI class + +function NicCLI(top) { + this.top = top.top; + + Cmdln.call(this, { + name: top.name + ' nic', + desc: 'List and manage instance NICs.', + helpSubcmds: [ + 'help', + 'list', + 'get', + 'create', + 'delete' + ], + helpOpts: { + minHelpCol: 23 + } + }); +} +util.inherits(NicCLI, Cmdln); + +NicCLI.prototype.init = function init(opts, args, cb) { + this.log = this.top.log; + Cmdln.prototype.init.apply(this, arguments); +}; + +NicCLI.prototype.do_list = require('./do_list'); +NicCLI.prototype.do_create = require('./do_create'); +NicCLI.prototype.do_get = require('./do_get'); +NicCLI.prototype.do_delete = require('./do_delete'); + +module.exports = NicCLI; diff --git a/lib/do_instance/index.js b/lib/do_instance/index.js index d8f1513..8dfb985 100644 --- a/lib/do_instance/index.js +++ b/lib/do_instance/index.js @@ -49,6 +49,7 @@ function InstanceCLI(top) { 'ip', 'wait', 'audit', + 'nic', 'snapshot', 'tag' ] @@ -81,6 +82,7 @@ InstanceCLI.prototype.do_ssh = require('./do_ssh'); InstanceCLI.prototype.do_ip = require('./do_ip'); InstanceCLI.prototype.do_wait = require('./do_wait'); InstanceCLI.prototype.do_audit = require('./do_audit'); +InstanceCLI.prototype.do_nic = require('./do_nic'); InstanceCLI.prototype.do_snapshot = require('./do_snapshot'); InstanceCLI.prototype.do_snapshots = require('./do_snapshots'); InstanceCLI.prototype.do_tag = require('./do_tag'); diff --git a/lib/tritonapi.js b/lib/tritonapi.js index b1683cc..c692e82 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -2029,6 +2029,230 @@ function deleteAllInstanceTags(opts, cb) { }; +// ---- nics + +/** + * Add a NIC on a network to an instance. + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {Object|String} network: The network object or ID, name, or short ID. + * Required. + * @param {Function} callback `function (err, nic, res)` + */ +TritonApi.prototype.addNic = +function addNic(opts, cb) { + assert.string(opts.id, 'opts.id'); + assert.ok(opts.network, 'opts.network'); + assert.func(cb, 'cb'); + + var self = this; + var pipeline = []; + var res; + var nic; + + switch (typeof (opts.network)) { + case 'string': + pipeline.push(_stepNetId); + break; + case 'object': + break; + default: + throw new Error('unexpected opts.network: ' + opts.network); + } + + pipeline.push(_stepInstId); + pipeline.push(function createNic(arg, next) { + self.cloudapi.addNic({ + id: arg.instId, + network: arg.netId || arg.network + }, function onCreateNic(err, _nic, _res) { + res = _res; + res.instId = arg.instId; // gross hack, in case caller needs it + res.netId = arg.netId; // ditto + nic = _nic; + next(err); + }); + }); + + var pipelineArg = { + client: self, + id: opts.id, + network: opts.network + }; + + vasync.pipeline({ + arg: pipelineArg, + funcs: pipeline + }, function (err) { + cb(err, nic, res); + }); +}; + + +/** + * List an instance's NICs. + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * @param {Function} callback `function (err, nics, res)` + */ +TritonApi.prototype.listNics = +function listNics(opts, cb) { + assert.string(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + var self = this; + var res; + var nics; + + vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [ + _stepInstId, + + function list(arg, next) { + self.cloudapi.listNics({ + id: arg.instId + }, function (err, _nics, _res) { + res = _res; + res.instId = arg.instId; // gross hack, in case caller needs it + nics = _nics; + next(err); + }); + } + ]}, function (err) { + cb(err, nics, res); + }); +}; + + +/** + * Get a NIC belonging to an instance. + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {String} mac: The NIC's MAC address. Required. + * @param {Function} callback `function (err, nic, res)` + */ +TritonApi.prototype.getNic = +function getNic(opts, cb) { + assert.string(opts.id, 'opts.id'); + assert.string(opts.mac, 'opts.mac'); + assert.func(cb, 'cb'); + + var self = this; + var res; + var nic; + + vasync.pipeline({arg: {client: self, id: opts.id, mac: opts.mac}, funcs: [ + _stepInstId, + + function get(arg, next) { + self.cloudapi.getNic({ + id: arg.instId, + mac: arg.mac + }, function (err, _nic, _res) { + res = _res; + res.instId = arg.instId; // gross hack, in case caller needs it + nic = _nic; + next(err); + }); + } + ]}, function (err) { + cb(err, nic, res); + }); +}; + + +/** + * Remove a NIC from an instance. + * + * @param {Object} opts + * - {String} id: The instance ID, name, or short ID. Required. + * - {String} mac: The NIC's MAC address. Required. + * @param {Function} callback `function (err, res)` + * + */ +TritonApi.prototype.removeNic = +function removeNic(opts, cb) { + assert.string(opts.id, 'opts.id'); + assert.string(opts.mac, 'opts.mac'); + assert.func(cb, 'cb'); + + var self = this; + var res; + + vasync.pipeline({arg: {client: self, id: opts.id, mac: opts.mac}, funcs: [ + _stepInstId, + + function deleteNic(arg, next) { + self.cloudapi.removeNic({ + id: arg.instId, + mac: arg.mac + }, function (err, _res) { + res = _res; + res.instId = arg.instId; // gross hack, in case caller needs it + next(err); + }); + } + ]}, function (err) { + cb(err, res); + }); +}; + + +/** + * Wrapper for cloudapi2's waitForNicStates that will first translate + * opts.id into the proper uuid from shortid/name. + * + * @param {Object} options + * - {String} id {required} machine id + * - {String} mac {required} mac for new nic + * - {Array of String} states - desired state + * @param {Function} callback of the form f(err, nic, res). + */ +TritonApi.prototype.waitForNicStates = function waitForNicStates(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.string(opts.mac, 'opts.mac'); + assert.arrayOfString(opts.states, 'opts.states'); + + var self = this; + var nic, res; + + function waitForNic(arg, next) { + var _opts = { + id: arg.instId, + mac: arg.mac, + states: arg.states + }; + + self.cloudapi.waitForNicStates(_opts, + function onWaitForNicState(err, _nic, _res) { + res = _res; + nic = _nic; + next(err); + }); + } + + var pipelineArgs = { + client: self, + id: opts.id, + mac: opts.mac, + states: opts.states + }; + + vasync.pipeline({ + arg: pipelineArgs, + funcs: [ + _stepInstId, + waitForNic + ] + }, function onWaitForNicPipeline(err) { + cb(err, nic, res); + }); +}; + + // ---- Firewall Rules /** diff --git a/test/integration/api-nics.test.js b/test/integration/api-nics.test.js new file mode 100644 index 0000000..0e9094c --- /dev/null +++ b/test/integration/api-nics.test.js @@ -0,0 +1,110 @@ +/* + * 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 2018 Joyent, Inc. + */ + +/* + * Integration tests for using NIC-related APIs as a module. + */ + +var h = require('./helpers'); +var test = require('tape'); + + +// --- Globals + +var CLIENT; +var INST; +var NIC; + + +// --- Tests + +test('TritonApi networks', function (tt) { + tt.test(' setup', function (t) { + h.createClient(function (err, client_) { + t.error(err); + CLIENT = client_; + t.end(); + }); + }); + + + tt.test(' setup: inst', function (t) { + CLIENT.cloudapi.listMachines({}, function (err, vms) { + if (vms.length === 0) + return t.end(); + + t.ok(Array.isArray(vms), 'vms array'); + INST = vms[0]; + + t.end(); + }); + }); + + + tt.test(' TritonApi listNics', function (t) { + if (!INST) + return t.end(); + + function check(val, valName, next) { + CLIENT.listNics({id: val}, function (err, nics) { + if (h.ifErr(t, err, 'no err ' + valName)) + return t.end(); + + t.ok(Array.isArray(nics), 'nics array'); + NIC = nics[0]; + + next(); + }); + } + + var shortId = INST.id.split('-')[0]; + + check(INST.id, 'id', function () { + check(INST.name, 'name', function () { + check(shortId, 'shortId', function () { + t.end(); + }); + }); + }); + }); + + + tt.test(' TritonApi getNic', function (t) { + if (!NIC) + return t.end(); + + function check(inst, mac, instValName, next) { + CLIENT.getNic({id: inst, mac: mac}, function (err, nic) { + if (h.ifErr(t, err, 'no err for ' + instValName)) + return t.end(); + + t.deepEqual(nic, NIC, instValName); + + next(); + }); + } + + var shortId = INST.id.split('-')[0]; + + check(INST.id, NIC.mac, 'id', function () { + check(INST.name, NIC.mac, 'name', function () { + check(shortId, NIC.mac, 'shortId', function () { + t.end(); + }); + }); + }); + }); + + + tt.test(' teardown: client', function (t) { + CLIENT.close(); + t.end(); + }); +}); diff --git a/test/integration/cli-nics.test.js b/test/integration/cli-nics.test.js new file mode 100644 index 0000000..7131a3b --- /dev/null +++ b/test/integration/cli-nics.test.js @@ -0,0 +1,252 @@ +/* + * 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. + */ + +/* + * Integration tests for `triton instance nics ...` + */ + +var h = require('./helpers'); +var f = require('util').format; +var os = require('os'); +var test = require('tape'); + +// --- Globals + +var INST_ALIAS = f('nodetritontest-nics-%s', os.hostname()); +var NETWORK; +var INST; +var NIC; +var NIC2; + +var OPTS = { + skip: !h.CONFIG.allowWriteActions +}; + + +// --- Tests + +if (OPTS.skip) { + console.error('** skipping %s tests', __filename); + console.error('** set "allowWriteActions" in test config to enable'); +} + +test('triton instance nics', OPTS, function (tt) { + h.printConfig(tt); + + tt.test(' cleanup existing inst with alias ' + INST_ALIAS, function (t) { + h.deleteTestInst(t, INST_ALIAS, function (err) { + t.ifErr(err); + t.end(); + }); + }); + + tt.test(' setup: triton instance create', function (t) { + h.createTestInst(t, INST_ALIAS, function onInst(err, instId) { + if (h.ifErr(t, err, 'triton instance create')) + return t.end(); + + INST = instId; + + t.end(); + }); + }); + + tt.test(' setup: find network for tests', function (t) { + h.triton('network list -j', function onNetworks(err, stdout) { + if (h.ifErr(t, err, 'triton network list')) + return t.end(); + + NETWORK = JSON.parse(stdout.trim().split('\n')[0]); + t.ok(NETWORK, 'NETWORK'); + + t.end(); + }); + }); + + tt.test(' triton instance nic create', function (t) { + var cmd = 'instance nic create -j -w ' + INST + ' ' + NETWORK.id; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic create')) + return t.end(); + + NIC = JSON.parse(stdout); + + t.end(); + }); + }); + + tt.test(' triton instance nic get', function (t) { + var cmd = 'instance nic get ' + INST + ' ' + NIC.mac; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic get')) + return t.end(); + + var obj = JSON.parse(stdout); + t.equal(obj.mac, NIC.mac, 'nic MAC is correct'); + t.equal(obj.ip, NIC.ip, 'nic IP is correct'); + t.equal(obj.network, NIC.network, 'nic network is correct'); + + t.end(); + }); + }); + + tt.test(' triton instance nic list', function (t) { + var cmd = 'instance nic list ' + INST; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic list')) + return t.end(); + + var nics = stdout.trim().split('\n'); + t.ok(nics[0].match(/IP\s+MAC\s+STATE\s+NETWORK/), 'nic list' + + ' header correct'); + nics.shift(); + + t.ok(nics.length >= 1, 'triton nic list expected nic num'); + + var testNics = nics.filter(function (nic) { + return nic.match(NIC.mac); + }); + + t.equal(testNics.length, 1, 'triton nic list test nic found'); + + t.end(); + }); + }); + + tt.test(' triton instance nic list -j', function (t) { + var cmd = 'instance nic list -j ' + INST; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic list')) + return t.end(); + + var nics = stdout.trim().split('\n').map(function (line) { + return JSON.parse(line); + }); + + t.ok(nics.length >= 1, 'triton nic list expected nic num'); + + var testNics = nics.filter(function (nic) { + return nic.mac === NIC.mac; + }); + + t.equal(testNics.length, 1, 'triton nic list test nic found'); + + t.end(); + }); + }); + + tt.test(' triton instance nic list mac=<...>', function (t) { + var cmd = 'instance nic list -j ' + INST + ' mac=' + NIC.mac; + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err)) + return t.end(); + + var nics = stdout.trim().split('\n').map(function (str) { + return JSON.parse(str); + }); + + t.equal(nics.length, 1); + t.equal(nics[0].ip, NIC.ip); + t.equal(nics[0].network, NIC.network); + + t.end(); + }); + }); + + tt.test(' triton nic list mac=<...>', function (t) { + var cmd = 'instance nic list -j ' + INST + ' mac=' + NIC.mac; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err)) + return t.end(); + + var nics = stdout.trim().split('\n').map(function (str) { + return JSON.parse(str); + }); + + t.equal(nics.length, 1); + t.equal(nics[0].ip, NIC.ip); + t.equal(nics[0].network, NIC.network); + + t.end(); + }); + }); + + tt.test(' triton instance nic delete', function (t) { + var cmd = 'instance nic delete --force ' + INST + ' ' + NIC.mac; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic delete')) + return t.end(); + + t.ok(stdout.match('Deleted NIC ' + NIC.mac, 'deleted nic')); + + t.end(); + }); + }); + + tt.test(' triton instance nic create (with NICOPTS)', function (t) { + var cmd = 'instance nic create -j -w ' + INST + ' ipv4_uuid=' + + NETWORK.id; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic create')) + return t.end(); + + NIC2 = JSON.parse(stdout); + + t.end(); + }); + }); + + tt.test(' triton instance nic with ip get', function (t) { + var cmd = 'instance nic get ' + INST + ' ' + NIC2.mac; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic get')) + return t.end(); + + var obj = JSON.parse(stdout); + t.equal(obj.mac, NIC2.mac, 'nic MAC is correct'); + t.equal(obj.ip, NIC2.ip, 'nic IP is correct'); + t.equal(obj.network, NIC2.network, 'nic network is correct'); + + t.end(); + }); + }); + + tt.test(' triton instance nic with ip delete', function (t) { + var cmd = 'instance nic delete --force ' + INST + ' ' + NIC2.mac; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance nic with ip delete')) + return t.end(); + + t.ok(stdout.match('Deleted NIC ' + NIC2.mac, 'deleted nic')); + + t.end(); + }); + }); + + /* + * Use a timeout, because '-w' on delete doesn't have a way to know if the + * attempt failed or if it is just taking a really long time. + */ + tt.test(' cleanup: triton instance rm INST', {timeout: 10 * 60 * 1000}, + function (t) { + h.deleteTestInst(t, INST_ALIAS, function () { + t.end(); + }); + }); +}); diff --git a/test/integration/cli-subcommands.test.js b/test/integration/cli-subcommands.test.js index 0609179..1faf378 100644 --- a/test/integration/cli-subcommands.test.js +++ b/test/integration/cli-subcommands.test.js @@ -56,6 +56,10 @@ var subs = [ ['instance snapshot list', 'instance snapshot ls', 'instance snapshots'], ['instance snapshot get'], ['instance snapshot delete', 'instance snapshot rm'], + ['instance nic create'], + ['instance nic list', 'instance nic ls'], + ['instance nic get'], + ['instance nic delete', 'instance nic rm'], ['ip'], ['ssh'], ['network'],