diff --git a/CHANGES.md b/CHANGES.md index 92e075a..51acbd5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,66 @@ Known issues: ## not yet released +- [joyent/node-triton#245] `triton profile` now generates fresh new keys during + 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. + +## 6.0.0 + +This release containes some breaking changes with the --affinity flag to +`triton instance create`. It also does not work with cloudapi endpoints older +than 8.0.0 (mid 2016); for an older cloudapi endpoint, use node-triton 5.9.0. + +- [TRITON-167, TRITON-168] update support for + `triton instance create --affinity=...`. It now fully supports regular + expressions, tags and globs, and works across a wider variety of situations. + Examples: + + ``` + # regular expressions + triton instance create --affinity='instance!=/^production-db/' ... + + # globs + triton instance create --affinity='instance!=production-db*' ... + + # tags + triton instance create --affinity='role!=db' + ``` + + See for more details + how affinities work. + + However: + - Use of regular expressions requires a cloudapi version of 8.8.0 or later. + - 'inst' as a affinity shorthand no longer works. Use 'instance' instead. + E.g.: --affinity='instance==db1' instead of --affinity='inst==db1' + - The shorthand --affinity= no longer works. Use + --affinity='instance===' instead. + +## 5.10.0 + +- [TRITON-19] add support for deletion protection on instances. An instance with + the deletion protection flag set true cannot be destroyed until the flag is + set false. It is exposed through + `triton instance create --deletion-protection ...`, + `triton instance enable-deletion-protection ...`, and + `triton instance disable-deletion-protection ...`. This flag is only supported + on cloudapi versions 8.7.0 or above. +- [TRITON-59] node-triton should support nic operations + `triton instance nic get ...` + `triton instance nic create ...` + `triton instance nic list ...` + `triton instance nic delete ...` +- [TRITON-42] node-triton should support nics when creating an instance, e.g. + `triton instance create --nic IMAGE PACKAGE` + +## 5.9.0 + +- [TRITON-190] remove support for `triton instance create --brand=bhyve ...`. + The rest of bhyve support will remain, but selection of bhyve brand will + happen via images or packages that are bhyve-specific. + ## 5.8.0 - [TRITON-124] add node-triton support for bhyve. This adds a `triton instance diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 45924b9..94c57eb 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -154,7 +154,6 @@ function CloudApi(options) { this.client = new SaferJsonClient(options); } - CloudApi.prototype.close = function close(callback) { this.log.trace({host: this.client.url && this.client.url.host}, 'close cloudapi http client'); @@ -1006,7 +1005,6 @@ function enableMachineFirewall(uuid, callback) { return this._doMachine('enable_firewall', uuid, callback); }; - /** * Disables machine firewall. * @@ -1018,6 +1016,28 @@ function disableMachineFirewall(uuid, callback) { return this._doMachine('disable_firewall', uuid, callback); }; +/** + * Enables machine deletion protection. + * + * @param {String} id (required) The machine id. + * @param {Function} callback of the form `function (err, null, res)` + */ +CloudApi.prototype.enableMachineDeletionProtection = +function enableMachineDeletionProtection(uuid, callback) { + return this._doMachine('enable_deletion_protection', uuid, callback); +}; + +/** + * Disables machine deletion protection. + * + * @param {String} id (required) The machine id. + * @param {Function} callback of the form `function (err, null, res)` + */ +CloudApi.prototype.disableMachineDeletionProtection = +function disableMachineDeletionProtection(uuid, callback) { + return this._doMachine('disable_deletion_protection', uuid, callback); +}; + /** * internal function for start/stop/reboot/enable_firewall/disable_firewall */ @@ -1150,7 +1170,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'); @@ -1228,6 +1248,53 @@ function waitForMachineFirewallEnabled(opts, cb) { }; + +/** + * Wait for a machine's `deletion_protection` field to go true or + * false/undefined. + * + * @param {Object} options + * - {String} id: Required. The machine UUID. + * - {Boolean} state: Required. The desired `deletion_protection` state. + * - {Number} interval: Optional. Time (in ms) to poll. + * @param {Function} callback of the form f(err, machine, res). + */ +CloudApi.prototype.waitForDeletionProtectionEnabled = +function waitForDeletionProtectionEnabled(opts, cb) { + var self = this; + + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.bool(opts.state, 'opts.state'); + 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.getMachine({ + id: opts.id + }, function getMachineCb(err, machine, res) { + if (err) { + cb(err, null, res); + return; + } + + // !! converts an undefined to a false + if (opts.state === !!machine.deletion_protection) { + cb(null, machine, res); + return; + } + + setTimeout(poll, interval); + }); + } +}; + + // --- machine tags /** @@ -1536,6 +1603,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..613732e 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('./constants').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 + * `parseNicStr` to throw as it is not a valid UUID. + */ +function parseNicStr(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, + parseNicStr: parseNicStr }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/constants.js b/lib/constants.js index ef12d33..1cf068d 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -46,11 +46,18 @@ if (process.env.SCTEST_CLI_CONFIG_DIR) { CLI_CONFIG_DIR = mod_path.resolve(process.env.HOME, '.spearhead'); } +// -> +var NETWORK_OBJECT_FIELDS = { + ipv4_uuid: 'string', + ipv4_ips: 'string' +}; + // ---- exports module.exports = { - CLI_CONFIG_DIR: CLI_CONFIG_DIR + CLI_CONFIG_DIR: CLI_CONFIG_DIR, + NETWORK_OBJECT_FIELDS: NETWORK_OBJECT_FIELDS }; diff --git a/lib/do_account/do_update.js b/lib/do_account/do_update.js index 876debc..5e4d8c6 100644 --- a/lib/do_account/do_update.js +++ b/lib/do_account/do_update.js @@ -72,12 +72,8 @@ function do_update(subcmd, opts, args, callback) { next(); return; } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - process.stdin.on('end', function () { + + common.readStdin(function gotStdin(stdin) { try { ctx.data = JSON.parse(stdin); } catch (err) { @@ -92,36 +88,18 @@ function do_update(subcmd, opts, args, callback) { }, function validateIt(ctx, next) { - var keys = Object.keys(ctx.data); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var value = ctx.data[key]; - var type = UPDATE_ACCOUNT_FIELDS[key]; - if (!type) { - next(new errors.UsageError(format('unknown or ' + - 'unupdateable field: %s (updateable fields are: %s)', - key, - Object.keys(UPDATE_ACCOUNT_FIELDS).sort().join(', ')))); - return; - } - - if (typeof (value) !== type) { - next(new errors.UsageError(format('field "%s" must be ' + - 'of type "%s", but got a value of type "%s"', key, - type, typeof (value)))); - return; - } + try { + common.validateObject(ctx.data, UPDATE_ACCOUNT_FIELDS); + } catch (e) { + next(e); + return; } + next(); }, function updateAway(ctx, next) { var keys = Object.keys(ctx.data); - if (keys.length === 0) { - console.log('No fields given for account update'); - next(); - return; - } tritonapi.cloudapi.updateAccount(ctx.data, function (err) { if (err) { diff --git a/lib/do_fwrule/do_instances.js b/lib/do_fwrule/do_instances.js index b549f16..f16d8ec 100644 --- a/lib/do_fwrule/do_instances.js +++ b/lib/do_fwrule/do_instances.js @@ -115,6 +115,7 @@ function do_instances(subcmd, opts, args, cb) { if (inst.docker) flags.push('D'); if (inst.firewall_enabled) flags.push('F'); if (inst.brand === 'kvm') flags.push('K'); + if (inst.deletion_protection) flags.push('P'); inst.flags = flags.length ? flags.join('') : undefined; }); @@ -164,6 +165,7 @@ do_instances.help = [ ' "D" docker instance', ' "F" firewall is enabled', ' "K" the brand is "kvm"', + ' "P" deletion protected', ' age* Approximate time since created, e.g. 1y, 2w.', ' img* The image "name@version", if available, else its', ' "shortid".' diff --git a/lib/do_fwrule/do_update.js b/lib/do_fwrule/do_update.js index 9bad3e4..99f1304 100644 --- a/lib/do_fwrule/do_update.js +++ b/lib/do_fwrule/do_update.js @@ -84,14 +84,7 @@ function do_update(subcmd, opts, args, cb) { return; } - var stdin = ''; - - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - - process.stdin.on('end', function () { + common.readStdin(function gotStdin(stdin) { try { ctx.data = JSON.parse(stdin); } catch (err) { @@ -107,33 +100,13 @@ function do_update(subcmd, opts, args, cb) { }, function validateIt(ctx, next) { - var keys = Object.keys(ctx.data); - - if (keys.length === 0) { - console.log('No fields given for firewall rule update'); - next(); + try { + common.validateObject(ctx.data, UPDATE_FWRULE_FIELDS); + } catch (e) { + next(e); return; } - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var value = ctx.data[key]; - var type = UPDATE_FWRULE_FIELDS[key]; - if (!type) { - next(new errors.UsageError(format('unknown or ' + - 'unupdateable field: %s (updateable fields are: %s)', - key, - Object.keys(UPDATE_FWRULE_FIELDS).sort().join(', ')))); - return; - } - - if (typeof (value) !== type) { - next(new errors.UsageError(format('field "%s" must be ' + - 'of type "%s", but got a value of type "%s"', key, - type, typeof (value)))); - return; - } - } next(); }, diff --git a/lib/do_instance/do_create.js b/lib/do_instance/do_create.js index 96ac34a..1d1ea03 100644 --- a/lib/do_instance/do_create.js +++ b/lib/do_instance/do_create.js @@ -19,6 +19,8 @@ var common = require('../common'); var distractions = require('../distractions'); var errors = require('../errors'); var mat = require('../metadataandtags'); +var NETWORK_OBJECT_FIELDS = + require('../constants').NETWORK_OBJECT_FIELDS; function parseVolMount(volume) { var components; @@ -83,6 +85,9 @@ function do_create(subcmd, opts, args, cb) { return; } else if (args.length !== 2) { return cb(new errors.UsageError('incorrect number of args')); + } else if (opts.nic && opts.network) { + return cb(new errors.UsageError( + '--network and --nic cannot be specified together')); } var log = this.top.log; @@ -90,103 +95,6 @@ function do_create(subcmd, opts, args, cb) { vasync.pipeline({arg: {cli: this.top}, funcs: [ common.cliSetupTritonApi, - /* BEGIN JSSTYLED */ - /* - * Parse --affinity options for validity to `ctx.affinities`. - * Later (in `resolveLocality`) we'll translate this to locality hints - * that CloudAPI speaks. - * - * Some examples. Inspired by - * - * - * instance==vm1 - * container==vm1 # alternative to 'instance' - * inst==vm1 # alternative to 'instance' - * inst=vm1 # '=' is shortcut for '==' - * inst!=vm1 # '!=' - * inst==~vm1 # '~' for soft/non-strict - * inst!=~vm1 - * - * inst==vm* # globbing (not yet supported) - * inst!=/vm\d/ # regex (not yet supported) - * - * some-tag!=db # tags (not yet supported) - * - * Limitations: - * - no support for tags yet - * - no globbing or regex yet - * - we resolve name -> instance id *client-side* for now (until - * CloudAPI supports that) - * - Triton doesn't support mixed strict and non-strict, so we error - * out on that. We *could* just drop the non-strict, but that is - * slightly different. - */ - /* END JSSTYLED */ - function parseAffinity(ctx, next) { - if (!opts.affinity) { - next(); - return; - } - - var affinities = []; - - // TODO: stricter rules on the value part - // JSSTYLED - var affinityRe = /((instance|inst|container)(==~|!=~|==|!=|=~|=))?(.*?)$/; - for (var i = 0; i < opts.affinity.length; i++) { - var raw = opts.affinity[i]; - var match = affinityRe.exec(raw); - if (!match) { - next(new errors.UsageError(format('invalid affinity: "%s"', - raw))); - return; - } - - var key = match[2]; - if ([undefined, 'inst', 'container'].indexOf(key) !== -1) { - key = 'instance'; - } - assert.equal(key, 'instance'); - var op = match[3]; - if ([undefined, '='].indexOf(op) !== -1) { - op = '=='; - } - var strict = true; - if (op[op.length - 1] === '~') { - strict = false; - op = op.slice(0, op.length - 1); - } - var val = match[4]; - - // Guard against mixed strictness (Triton can't handle those). - if (affinities.length > 0) { - var lastAff = affinities[affinities.length - 1]; - if (strict !== lastAff.strict) { - next(new errors.TritonError(format('mixed strict and ' - + 'non-strict affinities are not supported: ' - + '%j (%s) and %j (%s)', - lastAff.raw, - (lastAff.strict ? 'strict' : 'non-strict'), - raw, (strict ? 'strict' : 'non-strict')))); - return; - } - } - - affinities.push({ - raw: raw, - key: key, - op: op, - strict: strict, - val: val - }); - } - - if (affinities.length) { - log.trace({affinities: affinities}, 'affinities'); - ctx.affinities = affinities; - } - next(); - }, /* * Make sure if volumes were passed, they're in the correct form. @@ -225,61 +133,44 @@ function do_create(subcmd, opts, args, cb) { }, /* - * Determine `ctx.locality` according to what CloudAPI supports - * based on `ctx.affinities` parsed earlier. + * Parse any nics given via `--nic` */ - function resolveLocality(ctx, next) { - if (!ctx.affinities) { + function parseNics(ctx, next) { + if (!opts.nic) { next(); return; } - var strict; - var near = []; - var far = []; + ctx.nics = []; + var i; + var networksSeen = {}; + var nic; + var nics = opts.nic; - vasync.forEachPipeline({ - inputs: ctx.affinities, - func: function resolveAffinity(aff, nextAff) { - assert.ok(['==', '!='].indexOf(aff.op) !== -1, - 'unexpected op: ' + aff.op); - var nearFar = (aff.op == '==' ? near : far); + log.trace({nics: nics}, 'parsing nics'); - strict = aff.strict; - if (common.isUUID(aff.val)) { - nearFar.push(aff.val); - nextAff(); - } else { - tritonapi.getInstance({ - id: aff.val, - fields: ['id'] - }, function (err, inst) { - if (err) { - nextAff(err); - } else { - log.trace({val: aff.val, inst: inst.id}, - 'resolveAffinity'); - nearFar.push(inst.id); - nextAff(); - } - }); + for (i = 0; i < nics.length; i++) { + nic = nics[i].split(','); + + try { + nic = common.parseNicStr(nic); + if (networksSeen[nic.ipv4_uuid]) { + throw new errors.UsageError(format( + 'only 1 ip on a network allowed ' + + '(network %s specified multiple times)', + nic.ipv4_uuid)); } - } - }, function (err) { - if (err) { + networksSeen[nic.ipv4_uuid] = true; + ctx.nics.push(nic); + } catch (err) { next(err); return; } + } - ctx.locality = { - strict: strict - }; - if (near.length > 0) ctx.locality.near = near; - if (far.length > 0) ctx.locality.far = far; - log.trace({locality: ctx.locality}, 'resolveLocality'); + log.trace({nics: ctx.nics}, 'parsed nics'); - next(); - }); + next(); }, function loadMetadata(ctx, next) { @@ -371,19 +262,22 @@ function do_create(subcmd, opts, args, cb) { var createOpts = { name: opts.name, image: ctx.img.id, - 'package': ctx.pkg && ctx.pkg.id, - networks: ctx.nets && ctx.nets.map( - function (net) { return net.id; }) + 'package': ctx.pkg && ctx.pkg.id }; - if (opts.brand) { - createOpts.brand = opts.brand; + if (ctx.nets) { + createOpts.networks = ctx.nets.map(function (net) { + return net.id; + }); + } else if (ctx.nics) { + createOpts.networks = ctx.nics; } + if (ctx.volMounts) { createOpts.volumes = ctx.volMounts; } - if (ctx.locality) { - createOpts.locality = ctx.locality; + if (opts.affinity) { + createOpts.affinity = opts.affinity; } if (ctx.metadata) { Object.keys(ctx.metadata).forEach(function (key) { @@ -400,6 +294,8 @@ function do_create(subcmd, opts, args, cb) { var opt = opts._order[i]; if (opt.key === 'firewall') { createOpts.firewall_enabled = opt.value; + } else if (opt.key === 'deletion_protection') { + createOpts.deletion_protection = opt.value; } } @@ -495,13 +391,6 @@ do_create.options = [ { group: 'Create options' }, - { - names: ['brand'], - helpArg: 'BRAND', - type: 'string', - help: 'Override the default brand for this instance. Most users will ' + - 'not need this option.' - }, { names: ['name', 'n'], helpArg: 'NAME', @@ -530,9 +419,7 @@ do_create.options = [ 'INST), `instance==~INST` (*attempt* to place on the same server ' + 'as INST), or `instance!=~INST` (*attempt* to place on a server ' + 'other than INST\'s). `INST` is an existing instance name or ' + - 'id. There are two shortcuts: `inst` may be used instead of ' + - '`instance` and `instance==INST` can be shortened to just ' + - '`INST`. Use this option more than once for multiple rules.', + 'id. Use this option more than once for multiple rules.', completionType: 'tritonaffinityrule' }, @@ -547,6 +434,15 @@ do_create.options = [ 'This option can be used multiple times.', completionType: 'tritonnetwork' }, + { + names: ['nic'], + type: 'arrayOfString', + helpArg: 'NICOPTS', + help: 'A network interface object containing comma separated ' + + 'key=value pairs (Network object format). ' + + 'This option can be used multiple times for multiple NICs. ' + + 'Valid keys are: ' + Object.keys(NETWORK_OBJECT_FIELDS).join(', ') + }, { // TODO: add boolNegationPrefix:'no-' when that cmdln pull is in names: ['firewall'], @@ -554,6 +450,13 @@ do_create.options = [ help: 'Enable Cloud Firewall on this instance. See ' + '' }, + { + names: ['deletion-protection'], + type: 'bool', + help: 'Enable Deletion Protection on this instance. Such an instance ' + + 'cannot be deleted until the protection is disabled. See ' + + '' + }, { names: ['volume', 'v'], type: 'arrayOfString', diff --git a/lib/do_instance/do_disable_deletion_protection.js b/lib/do_instance/do_disable_deletion_protection.js new file mode 100644 index 0000000..011201b --- /dev/null +++ b/lib/do_instance/do_disable_deletion_protection.js @@ -0,0 +1,125 @@ +/* + * 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 disable-deletion-protection ...` + */ + +var assert = require('assert-plus'); +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + + +function do_disable_deletion_protection(subcmd, opts, args, cb) { + assert.object(opts, 'opts'); + assert.arrayOfString(args, 'args'); + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length === 0) { + cb(new errors.UsageError('missing INST argument(s)')); + return; + } + + var cli = this.top; + + function wait(name, id, next) { + assert.string(name, 'name'); + assert.uuid(id, 'id'); + assert.func(next, 'next'); + + cli.tritonapi.cloudapi.waitForDeletionProtectionEnabled({ + id: id, + state: false + }, function (err, inst) { + if (err) { + next(err); + return; + } + + assert.ok(!inst.deletion_protection, 'inst ' + id + + ' deletion_protection not in expected state after ' + + 'waitForDeletionProtectionEnabled'); + + console.log('Disabled deletion protection for instance "%s"', name); + next(); + }); + } + + function disableOne(name, next) { + assert.string(name, 'name'); + assert.func(next, 'next'); + + cli.tritonapi.disableInstanceDeletionProtection({ + id: name + }, function disableProtectionCb(err, fauxInst) { + if (err) { + next(err); + return; + } + + console.log('Disabling deletion protection for instance "%s"', + name); + + if (opts.wait) { + wait(name, fauxInst.id, next); + } else { + next(); + } + }); + } + + common.cliSetupTritonApi({cli: cli}, function onSetup(setupErr) { + if (setupErr) { + cb(setupErr); + return; + } + + vasync.forEachParallel({ + inputs: args, + func: disableOne + }, function vasyncCb(err) { + cb(err); + }); + }); +} + + +do_disable_deletion_protection.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for deletion protection to be removed.' + } +]; +do_disable_deletion_protection.synopses = [ + '{{name}} disable-deletion-protection [OPTIONS] INST [INST ...]' +]; +do_disable_deletion_protection.help = [ + 'Disable deletion protection on one or more instances.', + '', + '{{usage}}', + '', + '{{options}}', + 'Where "INST" is an instance name, id, or short id.' +].join('\n'); + +do_disable_deletion_protection.completionArgtypes = ['tritoninstance']; + +module.exports = do_disable_deletion_protection; diff --git a/lib/do_instance/do_enable_deletion_protection.js b/lib/do_instance/do_enable_deletion_protection.js new file mode 100644 index 0000000..a598379 --- /dev/null +++ b/lib/do_instance/do_enable_deletion_protection.js @@ -0,0 +1,125 @@ +/* + * 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 enable-deletion-protection ...` + */ + +var assert = require('assert-plus'); +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); + + +function do_enable_deletion_protection(subcmd, opts, args, cb) { + assert.object(opts, 'opts'); + assert.arrayOfString(args, 'args'); + assert.func(cb, 'cb'); + + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + if (args.length === 0) { + cb(new errors.UsageError('missing INST argument(s)')); + return; + } + + var cli = this.top; + + function wait(name, id, next) { + assert.string(name, 'name'); + assert.uuid(id, 'id'); + assert.func(next, 'next'); + + cli.tritonapi.cloudapi.waitForDeletionProtectionEnabled({ + id: id, + state: true + }, function (err, inst) { + if (err) { + next(err); + return; + } + + assert.ok(inst.deletion_protection, 'inst ' + id + + ' deletion_protection not in expected state after ' + + 'waitForDeletionProtectionEnabled'); + + console.log('Enabled deletion protection for instance "%s"', name); + next(); + }); + } + + function enableOne(name, next) { + assert.string(name, 'name'); + assert.func(next, 'next'); + + cli.tritonapi.enableInstanceDeletionProtection({ + id: name + }, function enableProtectionCb(err, fauxInst) { + if (err) { + next(err); + return; + } + + console.log('Enabling deletion protection for instance "%s"', + name); + + if (opts.wait) { + wait(name, fauxInst.id, next); + } else { + next(); + } + }); + } + + common.cliSetupTritonApi({cli: cli}, function onSetup(setupErr) { + if (setupErr) { + cb(setupErr); + return; + } + + vasync.forEachParallel({ + inputs: args, + func: enableOne + }, function vasyncCb(err) { + cb(err); + }); + }); +} + + +do_enable_deletion_protection.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['wait', 'w'], + type: 'bool', + help: 'Wait for deletion protection to be enabled.' + } +]; +do_enable_deletion_protection.synopses = [ + '{{name}} enable-deletion-protection [OPTIONS] INST [INST ...]' +]; +do_enable_deletion_protection.help = [ + 'Enable deletion protection for one or more instances.', + '', + '{{usage}}', + '', + '{{options}}', + 'Where "INST" is an instance name, id, or short id.' +].join('\n'); + +do_enable_deletion_protection.completionArgtypes = ['tritoninstance']; + +module.exports = do_enable_deletion_protection; diff --git a/lib/do_instance/do_list.js b/lib/do_instance/do_list.js index 3892efc..19b04ea 100644 --- a/lib/do_instance/do_list.js +++ b/lib/do_instance/do_list.js @@ -154,6 +154,7 @@ function do_list(subcmd, opts, args, callback) { if (inst.docker) flags.push('D'); if (inst.firewall_enabled) flags.push('F'); if (inst.brand === 'kvm') flags.push('K'); + if (inst.deletion_protection) flags.push('P'); inst.flags = flags.length ? flags.join('') : undefined; }); @@ -213,6 +214,7 @@ do_list.help = [ ' "D" docker instance', ' "F" firewall is enabled', ' "K" the brand is "kvm"', + ' "P" deletion protected', ' age* Approximate time since created, e.g. 1y, 2w.', ' img* The image "name@version", if available, else its', ' "shortid".', 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..058f221 --- /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.parseNicStr(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 2183f40..7940ee0 100644 --- a/lib/do_instance/index.js +++ b/lib/do_instance/index.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2015 Joyent, Inc. + * Copyright 2018 Joyent, Inc. * * `triton instance ...` */ @@ -45,10 +45,14 @@ function InstanceCLI(top) { 'enable-firewall', 'disable-firewall', { group: '' }, + 'enable-deletion-protection', + 'disable-deletion-protection', + { group: '' }, 'ssh', 'ip', 'wait', 'audit', + 'nic', 'snapshot', 'tag' ] @@ -77,10 +81,16 @@ InstanceCLI.prototype.do_fwrules = require('./do_fwrules'); InstanceCLI.prototype.do_enable_firewall = require('./do_enable_firewall'); InstanceCLI.prototype.do_disable_firewall = require('./do_disable_firewall'); +InstanceCLI.prototype.do_enable_deletion_protection = + require('./do_enable_deletion_protection'); +InstanceCLI.prototype.do_disable_deletion_protection = + require('./do_disable_deletion_protection'); + 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/do_key/do_add.js b/lib/do_key/do_add.js index b868b53..7f9e517 100644 --- a/lib/do_key/do_add.js +++ b/lib/do_key/do_add.js @@ -47,13 +47,7 @@ function do_add(subcmd, opts, args, cb) { return next(); } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - - process.stdin.on('end', function () { + common.readStdin(function gotStdin(stdin) { ctx.data = stdin; ctx.from = ''; next(); diff --git a/lib/do_profile/do_create.js b/lib/do_profile/do_create.js index 5b4aadb..db0e21c 100644 --- a/lib/do_profile/do_create.js +++ b/lib/do_profile/do_create.js @@ -93,12 +93,8 @@ function _createProfile(opts, cb) { next(); return; } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - process.stdin.on('end', function () { + + common.readStdin(function gotStdin(stdin) { try { data = JSON.parse(stdin); } catch (err) { diff --git a/lib/do_profile/do_docker_setup.js b/lib/do_profile/do_docker_setup.js index 7c880e9..30803f7 100644 --- a/lib/do_profile/do_docker_setup.js +++ b/lib/do_profile/do_docker_setup.js @@ -23,7 +23,8 @@ function do_docker_setup(subcmd, opts, args, cb) { cli: this.top, name: profileName, implicit: false, - yes: opts.yes + yes: opts.yes, + lifetime: opts.lifetime }, cb); } @@ -33,6 +34,11 @@ do_docker_setup.options = [ type: 'bool', help: 'Show this help.' }, + { + names: ['lifetime', 't'], + type: 'number', + help: 'Lifetime of the generated docker certificate, in days' + }, { names: ['yes', 'y'], type: 'bool', diff --git a/lib/do_profile/profilecommon.js b/lib/do_profile/profilecommon.js index 0e530f5..2e25f4d 100644 --- a/lib/do_profile/profilecommon.js +++ b/lib/do_profile/profilecommon.js @@ -24,6 +24,7 @@ var rimraf = require('rimraf'); var semver = require('semver'); var sshpk = require('sshpk'); var mod_url = require('url'); +var crypto = require('crypto'); var vasync = require('vasync'); var which = require('which'); var wordwrap = require('wordwrap')(78); @@ -128,7 +129,6 @@ 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, @@ -143,14 +143,21 @@ function setCurrentProfile(opts, cb) { * implicit, we silently skip if ListServices shows no Docker service. * - {Boolean} yes: Optional. Boolean indicating if confirmation prompts * should be skipped, assuming a "yes" answer. + * - {Number} lifetime: Optional. Number of days to make the Docker + * certificate valid for. Defaults to 3650 (10 years). */ function profileDockerSetup(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.optionalBool(opts.implicit, 'opts.implicit'); assert.optionalBool(opts.yes, 'opts.yes'); + assert.optionalNumber(opts.lifetime, 'opts.lifetime'); assert.func(cb, 'cb'); + /* Default to a 10 year certificate. */ + if (!opts.lifetime) + opts.lifetime = 3650; + var cli = opts.cli; var tritonapi = cli.tritonapiFromProfileName({profileName: opts.name}); @@ -165,13 +172,17 @@ function profileDockerSetup(opts, cb) { function dockerKeyWarning(arg, next) { console.log(wordwrap('WARNING: Docker uses authentication via ' + 'client TLS certificates that do not support encrypted ' + - '(passphrase protected) keys or SSH agents. If you continue, ' + - 'this profile setup will attempt to write a copy of your ' + - 'SSH private key formatted as an unencrypted TLS certificate ' + - 'in "~/.spearhead/docker" for use by the Docker client.\n')); + '(passphrase protected) keys or SSH agents.\n')); + console.log(wordwrap('If you continue, this profile setup will ' + + 'create a fresh private key to be written unencrypted to ' + + 'disk in "~/.spearhead/docker" for use by the Docker client. ' + + 'This key will be useable only for Docker.\n')); if (yes) { next(); return; + } else { + console.log(wordwrap('If you do not specifically want to use ' + + 'Docker, you can answer "no" here.\n')); } common.promptYesNo({msg: 'Continue? [y/n] '}, function (answer) { if (answer !== 'y') { @@ -311,79 +322,143 @@ function profileDockerSetup(opts, cb) { }); }, - /* - * We need the private key to format as a client cert. If this profile's - * key was found in the SSH agent (and by default it prefers to take - * it from there), then we can't use `tritonapi.keyPair`, because - * the SSH agent protocol will not allow us access to the private key - * data (by design). - * - * As a fallback we'll look (via KeyRing) for a local copy of the - * private key to use, and then unlock it if necessary. - */ - function getPrivKey(arg, next) { - // If the key pair already works, then use that... - try { - arg.privKey = tritonapi.keyPair.getPrivateKey(); - next(); - return; - } catch (_) { - // ... else fall through. - } - + function getSigningKey(arg, next) { var kr = new auth.KeyRing(); - var profileFp = sshpk.parseFingerprint(tritonapi.profile.keyId); - kr.find(profileFp, function (findErr, keyPairs) { + var profileFp = sshpk.parseFingerprint(profile.keyId); + kr.findSigningKeyPair(profileFp, + function unlockAndStash(findErr, keyPair) { + if (findErr) { next(findErr); return; } - /* - * If our keyId was found, and with the 'homedir' plugin, then - * we should have access to the private key (modulo unlocking). - */ - var homedirKeyPair; - for (var i = 0; i < keyPairs.length; i++) { - if (keyPairs[i].plugin === 'homedir') { - homedirKeyPair = keyPairs[i]; - break; - } - } - if (homedirKeyPair) { - common.promptPassphraseUnlockKey({ - // Fake the `tritonapi` object, only `.keyPair` is used. - tritonapi: {keyPair: homedirKeyPair} - }, function (unlockErr) { - if (unlockErr) { - next(unlockErr); - return; - } - try { - arg.privKey = homedirKeyPair.getPrivateKey(); - } catch (homedirErr) { - next(new errors.SetupError(homedirErr, format( - 'could not obtain SSH private key for keyId ' + - '"%s" to create Docker certificate', - profile.keyId))); - return; - } - next(); - }); - } else { - next(new errors.SetupError(format('could not obtain SSH ' + - 'private key for keyId "%s" to create Docker ' + - 'certificate', profile.keyId))); + arg.signKeyPair = keyPair; + if (!keyPair.isLocked()) { + next(); + return; } + + common.promptPassphraseUnlockKey({ + /* Fake the `tritonapi` object, only `.keyPair` is used. */ + tritonapi: { keyPair: keyPair } + }, next); }); }, + function generateAndSignCert(arg, next) { + var key = arg.signKeyPair; + var pubKey = key.getPublicKey(); - function genClientCert_dir(arg, next) { + /* + * There isn't a particular reason this has to be ECDSA, but + * Docker supports it, and ECDSA keys are much easier to + * generate from inside node than RSA ones (since sshpk will + * do them for us instead of us shelling out and mucking with + * temporary files). + */ + arg.privKey = sshpk.generatePrivateKey('ecdsa'); + + var id = sshpk.identityFromDN('CN=' + profile.account); + var parentId = sshpk.identityFromDN('CN=' + + pubKey.fingerprint('md5').toString('base64')); + var serial = crypto.randomBytes(8); + /* + * Backdate the certificate by 5 minutes to account for clock + * sync -- we only allow 5 mins drift in cloudapi generally so + * using the same amount here seems fine. + */ + var validFrom = new Date(); + validFrom.setTime(validFrom.getTime() - 300*1000); + var validUntil = new Date(); + validUntil.setTime(validFrom.getTime() + + 24*3600*1000*opts.lifetime); + /* + * Generate it self-signed for now -- we will clear this + * signature out and replace it with the real one below. + */ + var cert = sshpk.createCertificate(id, arg.privKey, parentId, + arg.privKey, { validFrom: validFrom, validUntil: validUntil, + purposes: ['clientAuth', 'joyentDocker'], serial: serial }); + + var algo = pubKey.type + '-' + pubKey.defaultHashAlgorithm(); + + /* + * This code is using private API in sshpk because there is + * no public API as of 1.14.x for async signing of certificates. + * + * If the sshpk version in package.json is updated (even a + * patch bump) this code could break! This will be fixed up + * eventually, but for now we just have to be careful. + */ + var x509 = require('sshpk/lib/formats/x509'); + cert.signatures = {}; + cert.signatures.x509 = {}; + cert.signatures.x509.algo = algo; + var signer = key.createSign({ + user: profile.account, + algorithm: algo + }); + /* + * The smartdc-auth KeyPair signer produces an object with + * strings on it intended for http-signature instead of just a + * Signature instance (which is what the x509 format module + * expects). We wrap it up here to convert it. + */ + var signerConv = function (buf, ccb) { + signer(buf, function convertSignature(signErr, sigData) { + if (signErr) { + ccb(signErr); + return; + } + var algparts = sigData.algorithm.split('-'); + var sig = sshpk.parseSignature(sigData.signature, + algparts[0], 'asn1'); + sig.hashAlgorithm = algparts[1]; + sig.curve = pubKey.curve; + ccb(null, sig); + }); + }; + /* + * Sign a "test" string first to double-check the hash algo + * it's going to use. The SSH agent may not support SHA256 + * signatures, for example, and we will only find out by + * testing like this. + */ + signer('test', function afterTestSig(testErr, testSigData) { + + if (testErr) { + next(new errors.SetupError(testErr, format( + 'failed to sign Docker certificate using key ' + + '"%s"', profile.keyId))); + return; + } + + cert.signatures.x509.algo = testSigData.algorithm; + + x509.signAsync(cert, signerConv, + function afterCertSign(signErr) { + + if (signErr) { + next(new errors.SetupError(signErr, format( + 'failed to sign Docker certificate using key ' + + '"%s"', profile.keyId))); + return; + } + + cert.issuerKey = undefined; + /* Double-check that it came out ok. */ + assert.ok(cert.isSignedByKey(pubKey)); + arg.cert = cert; + next(); + }); + }); + }, + function makeClientCertDir(arg, next) { arg.dockerCertPath = path.resolve(cli.configDir, 'docker', common.profileSlug(profile)); mkdirp(arg.dockerCertPath, next); }, - function genClientCert_key(arg, next) { + function writeClientCertKey(arg, next) { arg.keyPath = path.resolve(arg.dockerCertPath, 'key.pem'); var data = arg.privKey.toBuffer('pkcs1'); fs.writeFile(arg.keyPath, data, function (err) { @@ -395,12 +470,9 @@ function profileDockerSetup(opts, cb) { } }); }, - function genClientCert_cert(arg, next) { + function writeClientCert(arg, next) { arg.certPath = path.resolve(arg.dockerCertPath, 'cert.pem'); - - var id = sshpk.identityFromDN('CN=' + profile.account); - var cert = sshpk.createSelfSignedCertificate(id, arg.privKey); - var data = cert.toBuffer('pem'); + var data = arg.cert.toBuffer('pem'); fs.writeFile(arg.certPath, data, function (err) { if (err) { diff --git a/lib/do_rbac/do_key.js b/lib/do_rbac/do_key.js index dbff62c..f9b658c 100644 --- a/lib/do_rbac/do_key.js +++ b/lib/do_rbac/do_key.js @@ -125,12 +125,8 @@ function _addUserKey(opts, cb) { if (opts.file !== '-') { return next(); } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - process.stdin.on('end', function () { + + common.readStdin(function gotStdin(stdin) { ctx.data = stdin; ctx.from = ''; next(); diff --git a/lib/do_rbac/do_policy.js b/lib/do_rbac/do_policy.js index 3e6b278..d1472a7 100644 --- a/lib/do_rbac/do_policy.js +++ b/lib/do_rbac/do_policy.js @@ -291,12 +291,8 @@ function _addPolicy(opts, cb) { if (opts.file !== '-') { return next(); } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - process.stdin.on('end', function () { + + common.readStdin(function gotStdin(stdin) { try { data = JSON.parse(stdin); } catch (err) { diff --git a/lib/do_rbac/do_role.js b/lib/do_rbac/do_role.js index 4fb56a7..7b8854e 100644 --- a/lib/do_rbac/do_role.js +++ b/lib/do_rbac/do_role.js @@ -287,12 +287,8 @@ function _addRole(opts, cb) { if (opts.file !== '-') { return next(); } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - process.stdin.on('end', function () { + + common.readStdin(function gotStdin(stdin) { try { data = JSON.parse(stdin); } catch (err) { diff --git a/lib/do_rbac/do_user.js b/lib/do_rbac/do_user.js index b3a755a..8dd8a40 100644 --- a/lib/do_rbac/do_user.js +++ b/lib/do_rbac/do_user.js @@ -282,12 +282,8 @@ function _addUser(opts, cb) { if (opts.file !== '-') { return next(); } - var stdin = ''; - process.stdin.resume(); - process.stdin.on('data', function (chunk) { - stdin += chunk; - }); - process.stdin.on('end', function () { + + common.readStdin(function gotStdin(stdin) { try { data = JSON.parse(stdin); } catch (err) { diff --git a/lib/tritonapi.js b/lib/tritonapi.js index b1683cc..7bc46df 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -133,7 +133,7 @@ var errors = require('./errors'); // ---- globals -var CLOUDAPI_ACCEPT_VERSION = '~8||~7'; +var CLOUDAPI_ACCEPT_VERSION = '~8'; @@ -1364,6 +1364,92 @@ function disableInstanceFirewall(opts, cb) { }; +// ---- instance enable/disable deletion protection + +/** + * Enable deletion protection on an instance. + * + * @param {Object} opts + * - {String} id: Required. The instance ID, name, or short ID. + * @param {Function} callback `function (err, fauxInst, res)` + * On failure `err` is an error instance, else it is null. + * On success: `fauxInst` is an object with just the instance id, + * `{id: }` and `res` is the CloudAPI + * `EnableMachineDeletionProtection` response. + * The API call does not return the instance/machine object, hence we + * are limited to just the id for `fauxInst`. + */ +TritonApi.prototype.enableInstanceDeletionProtection = +function enableInstanceDeletionProtection(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + var self = this; + var res; + var fauxInst; + + function enableDeletionProtection(arg, next) { + fauxInst = {id: arg.instId}; + + self.cloudapi.enableMachineDeletionProtection(arg.instId, + function enableCb(err, _, _res) { + res = _res; + next(err); + }); + } + + vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [ + _stepInstId, + enableDeletionProtection + ]}, function vasyncCb(err) { + cb(err, fauxInst, res); + }); +}; + + +/** + * Disable deletion protection on an instance. + * + * @param {Object} opts + * - {String} id: Required. The instance ID, name, or short ID. + * @param {Function} callback `function (err, fauxInst, res)` + * On failure `err` is an error instance, else it is null. + * On success: `fauxInst` is an object with just the instance id, + * `{id: }` and `res` is the CloudAPI + * `DisableMachineDeletionProtectiomn` response. + * The API call does not return the instance/machine object, hence we + * are limited to just the id for `fauxInst`. + */ +TritonApi.prototype.disableInstanceDeletionProtection = +function disableInstanceDeletionProtection(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + var self = this; + var res; + var fauxInst; + + function disableDeletionProtection(arg, next) { + fauxInst = {id: arg.instId}; + + self.cloudapi.disableMachineDeletionProtection(arg.instId, + function (err, _, _res) { + res = _res; + next(err); + }); + } + + vasync.pipeline({arg: {client: self, id: opts.id}, funcs: [ + _stepInstId, + disableDeletionProtection + ]}, function vasyncCb(err) { + cb(err, fauxInst, res); + }); +}; + + // ---- instance snapshots /** @@ -2029,6 +2115,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/package.json b/package.json index 96f8d4a..2e8d87e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spearhead", "description": "Spearhead Cloud CLI and client (https://spearhead.cloud)", - "version": "5.8.0", + "version": "6.0.0", "author": "Spearhead Systems (spearhead.systems)", "homepage": "https://code.spearhead.cloud/Spearhead/node-spearhead", "dependencies": { @@ -21,9 +21,9 @@ "restify-errors": "3.0.0", "rimraf": "2.4.4", "semver": "5.1.0", - "smartdc-auth": "2.5.6", - "sshpk": "1.10.2", - "sshpk-agent": "1.4.2", + "smartdc-auth": "2.5.7", + "sshpk": "1.14.1", + "sshpk-agent": "1.7.0", "strsplit": "1.0.0", "tabula": "1.10.0", "vasync": "1.6.3", diff --git a/test/config.json.sample b/test/config.json.sample index a9326b8..ec64816 100644 --- a/test/config.json.sample +++ b/test/config.json.sample @@ -26,11 +26,6 @@ // to true. "skipAffinityTests": false, - // Optional. Set to 'true' to skip testing of bhyve things. Some DCs might - // not support bhyve (no packages or images available, and/or no CNs with - // bhyve compatible hardware). - "skipBhyveTests": false, - // Optional. Set to 'true' to skip testing of KVM things. Some DCs might // not support KVM (no KVM packages or images available). "skipKvmTests": false, @@ -41,12 +36,6 @@ "resizePackage": "", "image": "" - // The params used for test *bhyve* provisions. By default the tests use: - // the smallest RAM package with "kvm" in the name, the latest - // ubuntu-certified image. - "bhyvePackage": "", - "bhyveImage": "", - // The params used for test *KVM* provisions. By default the tests use: // the smallest RAM package with "kvm" in the name, the latest // ubuntu-certified image. 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-affinity.test.js b/test/integration/cli-affinity.test.js index 5533ce7..f427aa6 100644 --- a/test/integration/cli-affinity.test.js +++ b/test/integration/cli-affinity.test.js @@ -81,7 +81,8 @@ test('affinity (triton create -a RULE ...)', testOpts, function (tt) { var db0Alias = ALIAS_PREFIX + '-db0'; var db0; tt.test(' setup: triton create -n db0', function (t) { - var argv = ['create', '-wj', '-n', db0Alias, imgId, pkgId]; + var argv = ['create', '-wj', '-n', db0Alias, '-t', 'role=database', + imgId, pkgId]; h.safeTriton(t, argv, function (err, stdout) { var lines = h.jsonStreamParse(stdout); db0 = lines[1]; @@ -92,9 +93,9 @@ test('affinity (triton create -a RULE ...)', testOpts, function (tt) { // Test db1 being put on same server as db0. var db1Alias = ALIAS_PREFIX + '-db1'; var db1; - tt.test(' setup: triton create -n db1 -a db0', function (t) { - var argv = ['create', '-wj', '-n', db1Alias, '-a', db0Alias, - imgId, pkgId]; + tt.test(' triton create -n db1 -a instance==db0', function (t) { + var argv = ['create', '-wj', '-n', db1Alias, '-a', + 'instance==' + db0Alias, imgId, pkgId]; h.safeTriton(t, argv, function (err, stdout) { var lines = h.jsonStreamParse(stdout); db1 = lines[1]; @@ -105,11 +106,11 @@ test('affinity (triton create -a RULE ...)', testOpts, function (tt) { }); }); - // Test db2 being put on server *other* than db0. + // Test db2 being put on a server without a db. var db2Alias = ALIAS_PREFIX + '-db2'; var db2; - tt.test(' setup: triton create -n db2 -a \'inst!=db0\'', function (t) { - var argv = ['create', '-wj', '-n', db2Alias, '-a', 'inst!='+db0Alias, + tt.test(' triton create -n db2 -a \'instance!=db*\'', function (t) { + var argv = ['create', '-wj', '-n', db2Alias, '-a', 'instance!=db*', imgId, pkgId]; h.safeTriton(t, argv, function (err, stdout) { var lines = h.jsonStreamParse(stdout); @@ -121,11 +122,45 @@ test('affinity (triton create -a RULE ...)', testOpts, function (tt) { }); }); + + // Test db3 being put on server *other* than db0. + var db3Alias = ALIAS_PREFIX + '-db3'; + var db3; + tt.test(' triton create -n db3 -a \'instance!=db0\'', function (t) { + var argv = ['create', '-wj', '-n', db3Alias, '-a', + 'instance!='+db0Alias, imgId, pkgId]; + h.safeTriton(t, argv, function (err, stdout) { + var lines = h.jsonStreamParse(stdout); + db3 = lines[1]; + t.notEqual(db0.compute_node, db3.compute_node, + format('inst %s landed on different CN (%s) as inst %s (%s)', + db3Alias, db3.compute_node, db0Alias, db0.compute_node)); + t.end(); + }); + }); + + // Test db4 being put on server *other* than db0 (due ot db0's tag). + var db4Alias = ALIAS_PREFIX + '-db4'; + var db4; + tt.test(' triton create -n db4 -a \'role!=database\'', function (t) { + var argv = ['create', '-wj', '-n', db4Alias, '-a', 'role!=database', + imgId, pkgId]; + h.safeTriton(t, argv, function (err, stdout) { + var lines = h.jsonStreamParse(stdout); + db4 = lines[1]; + t.notEqual(db0.compute_node, db4.compute_node, + format('inst %s landed on different CN (%s) as inst %s (%s)', + db4Alias, db4.compute_node, db0Alias, db0.compute_node)); + t.end(); + }); + }); + // Remove instances. Add a test 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 rm', {timeout: 10 * 60 * 1000}, function (t) { - h.safeTriton(t, ['rm', '-w', db0.id, db1.id, db2.id], function () { + h.safeTriton(t, ['rm', '-w', db0.id, db1.id, db2.id, db3.id, db4.id], + function () { t.end(); }); }); diff --git a/test/integration/cli-deletion-protection.test.js b/test/integration/cli-deletion-protection.test.js new file mode 100644 index 0000000..db01e6f --- /dev/null +++ b/test/integration/cli-deletion-protection.test.js @@ -0,0 +1,191 @@ +/* + * 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 enable-deletion-protection ...` and + * `triton instance disable-deletion-protection ...` + */ + +var h = require('./helpers'); +var f = require('util').format; +var os = require('os'); +var test = require('tape'); + +// --- Globals + +var INST_ALIAS = f('nodetritontest-deletion-protection-%s', os.hostname()); +var INST; +var OPTS = { + skip: !h.CONFIG.allowWriteActions +}; + +// --- Helpers + +function cleanup(t) { + var cmd = 'instance disable-deletion-protection ' + INST_ALIAS + ' -w'; + + h.triton(cmd, function (err, stdout, stderr) { + if (err) + return t.end(); + + h.deleteTestInst(t, INST_ALIAS, function (err2) { + t.ifErr(err2, 'delete inst err'); + t.end(); + }); + }); +} + +// --- Tests + +if (OPTS.skip) { + console.error('** skipping %s tests', __filename); + console.error('** set "allowWriteActions" in test config to enable'); +} + +test('triton instance', OPTS, function (tt) { + h.printConfig(tt); + + tt.test(' cleanup existing inst with alias ' + INST_ALIAS, cleanup); + + + tt.test(' triton create --deletion-protection', function (t) { + h.createTestInst(t, INST_ALIAS, { + extraFlags: ['--deletion-protection'] + }, function onInst(err2, instId) { + if (h.ifErr(t, err2, 'triton instance create')) + return t.end(); + + INST = instId; + + h.triton('instance get -j ' + INST, function (err3, stdout) { + if (h.ifErr(t, err3, 'triton instance get')) + return t.end(); + + var inst = JSON.parse(stdout); + t.ok(inst.deletion_protection, 'deletion_protection'); + + t.end(); + }); + }); + }); + + + tt.test(' attempt to delete deletion-protected instance', function (t) { + var cmd = 'instance rm ' + INST + ' -w'; + + h.triton(cmd, function (err, stdout, stderr) { + t.ok(err, 'err expected'); + /* JSSTYLED */ + t.ok(stderr.match(/Instance has "deletion_protection" enabled/)); + t.end(); + }); + }); + + + tt.test(' triton instance disable-deletion-protection', function (t) { + var cmd = 'instance disable-deletion-protection ' + INST + ' -w'; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance disable-deletion-protection')) + return t.end(); + + t.ok(stdout.match('Disabled deletion protection for instance "' + + INST + '"'), 'deletion protection disabled'); + + h.triton('instance get -j ' + INST, function (err2, stdout2) { + if (h.ifErr(t, err2, 'triton instance get')) + return t.end(); + + var inst = JSON.parse(stdout2); + t.ok(!inst.deletion_protection, 'deletion_protection'); + + t.end(); + }); + }); + }); + + + tt.test(' triton instance disable-deletion-protection (already enabled)', + function (t) { + var cmd = 'instance disable-deletion-protection ' + INST + ' -w'; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance disable-deletion-protection')) + return t.end(); + + t.ok(stdout.match('Disabled deletion protection for instance "' + + INST + '"'), 'deletion protection disabled'); + + h.triton('instance get -j ' + INST, function (err2, stdout2) { + if (h.ifErr(t, err2, 'triton instance get')) + return t.end(); + + var inst = JSON.parse(stdout2); + t.ok(!inst.deletion_protection, 'deletion_protection'); + + t.end(); + }); + }); + }); + + + tt.test(' triton instance enable-deletion-protection', function (t) { + var cmd = 'instance enable-deletion-protection ' + INST + ' -w'; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance enable-deletion-protection')) + return t.end(); + + t.ok(stdout.match('Enabled deletion protection for instance "' + + INST + '"'), 'deletion protection enabled'); + + h.triton('instance get -j ' + INST, function (err2, stdout2) { + if (h.ifErr(t, err2, 'triton instance get')) + return t.end(); + + var inst = JSON.parse(stdout2); + t.ok(inst.deletion_protection, 'deletion_protection'); + + t.end(); + }); + }); + }); + + + tt.test(' triton instance enable-deletion-protection (already enabled)', + function (t) { + var cmd = 'instance enable-deletion-protection ' + INST + ' -w'; + + h.triton(cmd, function (err, stdout, stderr) { + if (h.ifErr(t, err, 'triton instance enable-deletion-protection')) + return t.end(); + + t.ok(stdout.match('Enabled deletion protection for instance "' + + INST + '"'), 'deletion protection enabled'); + + h.triton('instance get -j ' + INST, function (err2, stdout2) { + if (h.ifErr(t, err2, 'triton instance get')) + return t.end(); + + var inst = JSON.parse(stdout2); + t.ok(inst.deletion_protection, 'deletion_protection'); + + 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 rm INST', {timeout: 10 * 60 * 1000}, cleanup); +}); diff --git a/test/integration/cli-fwrules.test.js b/test/integration/cli-fwrules.test.js index 6d93974..3b4d775 100644 --- a/test/integration/cli-fwrules.test.js +++ b/test/integration/cli-fwrules.test.js @@ -47,7 +47,7 @@ test('triton fwrule', OPTS, function (tt) { }); tt.test(' setup: triton create', function (t) { - h.createTestInst(t, INST_ALIAS, function onInst(err2, instId) { + h.createTestInst(t, INST_ALIAS, {}, function onInst(err2, instId) { if (h.ifErr(t, err2, 'triton instance create')) return t.end(); diff --git a/test/integration/cli-nics.test.js b/test/integration/cli-nics.test.js new file mode 100644 index 0000000..f4fe61b --- /dev/null +++ b/test/integration/cli-nics.test.js @@ -0,0 +1,254 @@ +/* + * 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(); + + t.ok(instId, 'created instance ' + instId); + 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.ok(NIC, 'created NIC: ' + stdout.trim()); + + 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-snapshots.test.js b/test/integration/cli-snapshots.test.js index 47ed0e3..194424c 100644 --- a/test/integration/cli-snapshots.test.js +++ b/test/integration/cli-snapshots.test.js @@ -44,7 +44,7 @@ test('triton instance snapshot', OPTS, function (tt) { }); tt.test(' setup: triton instance create', function (t) { - h.createTestInst(t, INST_ALIAS, function onInst(err2, instId) { + h.createTestInst(t, INST_ALIAS, {}, function onInst(err2, instId) { if (h.ifErr(t, err2, 'triton instance create')) return t.end(); diff --git a/test/integration/cli-subcommands.test.js b/test/integration/cli-subcommands.test.js index 0609179..33173bf 100644 --- a/test/integration/cli-subcommands.test.js +++ b/test/integration/cli-subcommands.test.js @@ -45,6 +45,8 @@ var subs = [ ['instance delete', 'instance rm', 'delete', 'rm'], ['instance enable-firewall'], ['instance disable-firewall'], + ['instance enable-deletion-protection'], + ['instance disable-deletion-protection'], ['instance rename'], ['instance ssh'], ['instance ip'], @@ -56,6 +58,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'], diff --git a/test/integration/helpers.js b/test/integration/helpers.js index d68d436..7d7a658 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2018 Joyent, Inc. + * Copyright 2017 Joyent, Inc. */ /* @@ -211,46 +211,6 @@ function getTestImg(t, cb) { } -/* - * Find and return an image that can be used for test *bhyve* provisions. - * - * @param {Tape} t - tape test object - * @param {Function} cb - `function (err, imgId)` - * where `imgId` is an image identifier (an image name, shortid, or id). - */ -function getTestBhyveImg(t, cb) { - if (CONFIG.bhyveImage) { - assert.string(CONFIG.bhyvePackage, 'CONFIG.bhyvePackage'); - t.ok(CONFIG.bhyveImage, 'bhyveImage from config: ' + CONFIG.bhyveImage); - cb(null, CONFIG.bhyveImage); - return; - } - - var candidateImageNames = { - 'ubuntu-certified-16.04': true - }; - safeTriton(t, ['img', 'ls', '-j'], function (err, stdout) { - var imgId; - var imgs = jsonStreamParse(stdout); - // Newest images first. - tabula.sortArrayOfObjects(imgs, ['-published_at']); - var imgRepr; - for (var i = 0; i < imgs.length; i++) { - var img = imgs[i]; - if (candidateImageNames[img.name]) { - imgId = img.id; - imgRepr = f('%s@%s', img.name, img.version); - break; - } - } - - t.ok(imgId, - f('latest bhyve image (using subset of supported names): %s (%s)', - imgId, imgRepr)); - cb(err, imgId); - }); -} - /* * Find and return an image that can be used for test *KVM* provisions. * @@ -321,38 +281,6 @@ function getTestPkg(t, cb) { }); } -/* - * Find and return an package that can be used for *bhyve* test provisions. - * - * @param {Tape} t - tape test object - * @param {Function} cb - `function (err, pkgId)` - * where `pkgId` is an package identifier (a name, shortid, or id). - */ -function getTestBhyvePkg(t, cb) { - if (CONFIG.bhyvePackage) { - assert.string(CONFIG.bhyvePackage, 'CONFIG.bhyvePackage'); - t.ok(CONFIG.bhyvePackage, 'bhyvePackage from config: ' + - CONFIG.bhyvePackage); - cb(null, CONFIG.bhyvePackage); - return; - } - - // bhyve uses the same packages as kvm - safeTriton(t, ['pkg', 'ls', '-j'], function (err, stdout) { - var pkgs = jsonStreamParse(stdout); - // Filter on those with 'kvm' in the name. - pkgs = pkgs.filter(function (pkg) { - return pkg.name.indexOf('kvm') !== -1; - }); - // Smallest RAM first. - tabula.sortArrayOfObjects(pkgs, ['memory']); - var pkgId = pkgs[0].id; - t.ok(pkgId, f('smallest (RAM) available kvm package: %s (%s)', - pkgId, pkgs[0].name)); - cb(null, pkgId); - }); -} - /* * Find and return an package that can be used for *KVM* test provisions. * @@ -441,7 +369,13 @@ function createClient(cb) { /* * Create a small test instance. */ -function createTestInst(t, name, cb) { +function createTestInst(t, name, opts, cb) { + assert.object(t, 't'); + assert.string(name, 'name'); + assert.object(opts, 'opts'); + assert.optionalArrayOfString(opts.extraFlags, 'opts.extraFlags'); + assert.func(cb, 'cb'); + getTestPkg(t, function (err, pkgId) { t.ifErr(err); if (err) { @@ -457,6 +391,10 @@ function createTestInst(t, name, cb) { } var cmd = f('instance create -w -n %s %s %s', name, imgId, pkgId); + if (opts.extraFlags) { + cmd += ' ' + opts.extraFlags.join(' '); + } + triton(cmd, function (err3, stdout) { t.ifErr(err3, 'create test instance'); if (err3) { @@ -583,10 +521,8 @@ module.exports = { deleteTestImg: deleteTestImg, getTestImg: getTestImg, - getTestBhyveImg: getTestBhyveImg, getTestKvmImg: getTestKvmImg, getTestPkg: getTestPkg, - getTestBhyvePkg: getTestBhyvePkg, getTestKvmPkg: getTestKvmPkg, getResizeTestPkg: getResizeTestPkg,