TRITON-19 Triton equivalent to AWS' termination protection

Reviewed by: Trent Mick <trentm@gmail.com>
Approved by: Trent Mick <trentm@gmail.com>
This commit is contained in:
Marsell Kukuljevic 2018-02-24 02:16:23 +13:00
parent 002171ea06
commit 8e6cf27121
15 changed files with 642 additions and 6 deletions

View File

@ -6,6 +6,14 @@ Known issues:
## not yet released
- [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.
## 5.9.0
- [TRITON-190] remove support for `triton instance create --brand=bhyve ...`.

View File

@ -1012,7 +1012,6 @@ function enableMachineFirewall(uuid, callback) {
return this._doMachine('enable_firewall', uuid, callback);
};
/**
* Disables machine firewall.
*
@ -1024,6 +1023,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
*/
@ -1234,6 +1255,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
/**

View File

@ -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".'

View File

@ -397,6 +397,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;
}
}
@ -544,6 +546,13 @@ do_create.options = [
help: 'Enable Cloud Firewall on this instance. See ' +
'<https://docs.joyent.com/public-cloud/network/firewall>'
},
{
names: ['deletion-protection'],
type: 'bool',
help: 'Enable Deletion Protection on this instance. Such an instance ' +
'cannot be deleted until the protection is disabled. See ' +
'<https://apidocs.joyent.com/cloudapi/#deletion-protection>'
},
{
names: ['volume', 'v'],
type: 'arrayOfString',

View File

@ -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;

View File

@ -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;

View File

@ -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".',

View File

@ -5,7 +5,7 @@
*/
/*
* Copyright 2015 Joyent, Inc.
* Copyright 2018 Joyent, Inc.
*
* `triton instance ...`
*/
@ -45,6 +45,9 @@ function InstanceCLI(top) {
'enable-firewall',
'disable-firewall',
{ group: '' },
'enable-deletion-protection',
'disable-deletion-protection',
{ group: '' },
'ssh',
'ip',
'wait',
@ -78,6 +81,11 @@ 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');

View File

@ -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: <instance UUID>}` 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: <instance UUID>}` 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
/**

View File

@ -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);
});

View File

@ -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();

View File

@ -48,7 +48,7 @@ test('triton instance nics', OPTS, function (tt) {
});
tt.test(' setup: triton instance create', function (t) {
h.createTestInst(t, INST_ALIAS, function onInst(err, instId) {
h.createTestInst(t, INST_ALIAS, {}, function onInst(err, instId) {
if (h.ifErr(t, err, 'triton instance create'))
return t.end();

View File

@ -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();

View File

@ -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'],

View File

@ -369,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) {
@ -385,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) {