diff --git a/packages/compute/src/vm.js b/packages/compute/src/vm.js index 4907fd4e912..a0e80650da8 100644 --- a/packages/compute/src/vm.js +++ b/packages/compute/src/vm.js @@ -41,9 +41,40 @@ var Disk = require('./disk.js'); * @param {string} message - Custom error message. * @return {Error} */ -var DetachDiskError = createErrorClass('DetachDiskError', function(message) { - this.message = message; -}); +var DetachDiskError = createErrorClass('DetachDiskError'); + +/** + * Custom error type for when `waitFor()` does not return a status in a timely + * fashion. + * + * @private + * + * @param {string} message - Custom error message. + * @return {Error} + */ +var WaitForTimeoutError = createErrorClass('WaitForTimeoutError'); + +/** + * The statuses that a VM can be in. + * + * @private + */ +var VALID_STATUSES = [ + 'PROVISIONING', + 'STAGING', + 'RUNNING', + 'STOPPING', + 'SUSPENDING', + 'SUSPENDED', + 'TERMINATED' +]; + +/** + * Interval for polling during waitFor. + * + * @private + */ +var WAIT_FOR_POLLING_INTERVAL_MS = 2000; /*! Developer Documentation * @@ -69,6 +100,9 @@ function VM(zone, name) { this.name = name.replace(/.*\/([^/]+)$/, '$1'); // Just the instance name. this.zone = zone; + this.hasActiveWaiters = false; + this.waiters = []; + this.url = format('{base}/{project}/zones/{zone}/instances/{name}', { base: 'https://www.googleapis.com/compute/v1/projects', project: zone.compute.projectId, @@ -779,6 +813,145 @@ VM.prototype.stop = function(callback) { }, callback || common.util.noop); }; +/** + * This function will callback when the VM is in the specified state. + * + * Will time out after the specified time (default: 300 seconds). + * + * @param {string} status - The status to wait for. This can be: + * - "PROVISIONING" + * - "STAGING" + * - "RUNNING" + * - "STOPPING" + * - "SUSPENDING" + * - "SUSPENDED" + * - "TERMINATED" + * @param {object=} options - Configuration object. + * @param {number} options.timeout - The number of seconds to wait until timing + * out, between `0` and `600`. Default: `300` + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while waiting for the + * status. + * @param {object} callback.metadata - The instance's metadata. + * + * @example + * vm.waitFor('RUNNING', function(err, metadata) { + * if (!err) { + * // The VM is running. + * } + * }); + * + * //- + * // By default, `waitFor` will timeout after 300 seconds while waiting for the + * // desired state to occur. This can be changed to any number between 0 and + * // 600. If the timeout is set to 0, it will poll once for status and then + * // timeout if the desired state is not reached. + * //- + * var options = { + * timeout: 600 + * }; + * + * vm.waitFor('TERMINATED', options, function(err, metadata) { + * if (!err) { + * // The VM is terminated. + * } + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * vm.waitFor('RUNNING', options).then(function(data) { + * var metadata = data[0]; + * }); + */ +VM.prototype.waitFor = function(status, options, callback) { + if (is.fn(options)) { + callback = options; + options = {}; + } + + options = options || {}; + + status = status.toUpperCase(); + + // The timeout should default to five minutes, be less than or equal to 10 + // minutes, and be greater than or equal to 0 seconds. + var timeout = 300; + + if (is.number(options.timeout)) { + timeout = Math.min(Math.max(options.timeout, 0), 600); + } + + if (VALID_STATUSES.indexOf(status) === -1) { + throw new Error('Status passed to waitFor is invalid.'); + } + + this.waiters.push({ + status: status, + timeout: timeout, + startTime: new Date() / 1000, + callback: callback + }); + + if (!this.hasActiveWaiters) { + this.hasActiveWaiters = true; + this.startPolling_(); + } +}; + +/** + * Poll `getMetadata` to check the VM's status. This runs a loop to ping + * the API on an interval. + * + * Note: This method is automatically called when a `waitFor()` call + * is made. + * + * @private + */ +VM.prototype.startPolling_ = function() { + var self = this; + + if (!this.hasActiveWaiters) { + return; + } + + this.getMetadata(function(err, metadata) { + var now = new Date() / 1000; + + var waitersToRemove = self.waiters.filter(function(waiter) { + if (err) { + waiter.callback(err); + return true; + } + + if (metadata.status === waiter.status) { + waiter.callback(null, metadata); + return true; + } + + if (now - waiter.startTime >= waiter.timeout) { + var waitForTimeoutError = new WaitForTimeoutError([ + 'waitFor timed out waiting for VM ' + self.name, + 'to be in status: ' + waiter.status + ].join(' ')); + waiter.callback(waitForTimeoutError); + return true; + } + }); + + waitersToRemove.forEach(function(waiter) { + self.waiters.splice(self.waiters.indexOf(waiter), 1); + }); + + self.hasActiveWaiters = self.waiters.length > 0; + + if (self.hasActiveWaiters) { + setTimeout(self.startPolling_.bind(self), WAIT_FOR_POLLING_INTERVAL_MS); + } + }); +}; + + /** * Make a new request object from the provided arguments and wrap the callback * to intercept non-successful responses. diff --git a/packages/compute/system-test/compute.js b/packages/compute/system-test/compute.js index 491b578db4c..99def95d496 100644 --- a/packages/compute/system-test/compute.js +++ b/packages/compute/system-test/compute.js @@ -1290,8 +1290,16 @@ describe('Compute', function() { vm.start(compute.execAfterOperation_(done)); }); - it('should stop', function(done) { - vm.stop(compute.execAfterOperation_(done)); + it('should stop and trigger STOPPING `waitFor` event', function(done) { + async.parallel([ + function(callback) { + vm.waitFor('STOPPING', { timeout: 600 }, callback); + }, + + function(callback) { + vm.stop(compute.execAfterOperation_(callback)); + } + ], done); }); }); diff --git a/packages/compute/test/vm.js b/packages/compute/test/vm.js index 99e06aa6b53..9f68155c6be 100644 --- a/packages/compute/test/vm.js +++ b/packages/compute/test/vm.js @@ -100,6 +100,14 @@ describe('VM', function() { assert.strictEqual(vm.name, VM_NAME); }); + it('should initialize hasActiveWaiters to false', function() { + assert.strictEqual(vm.hasActiveWaiters, false); + }); + + it('should initialize an empty waiter array', function() { + assert.deepEqual(vm.waiters, []); + }); + it('should localize the URL of the VM', function() { assert.strictEqual(vm.url, [ 'https://www.googleapis.com/compute/v1/projects', @@ -845,6 +853,268 @@ describe('VM', function() { }); }); + describe('waitFor', function() { + var VALID_STATUSES = [ + 'PROVISIONING', + 'STAGING', + 'RUNNING', + 'STOPPING', + 'SUSPENDING', + 'SUSPENDED', + 'TERMINATED' + ]; + + beforeEach(function() { + vm.startPolling_ = util.noop; + }); + + it('should throw if an invalid status is passed', function() { + assert.throws(function() { + vm.waitFor('It', assert.ifError); + }, new RegExp('Status passed to waitFor is invalid.')); + }); + + it('should accept valid statuses', function() { + assert.doesNotThrow(function() { + VALID_STATUSES.forEach(function(status) { + vm.waitFor(status, assert.ifError); + }); + }); + }); + + it('should accept lowercase status', function() { + assert.doesNotThrow(function() { + vm.waitFor('ProVisioning', assert.ifError); + assert.strictEqual(vm.waiters.pop().status, 'PROVISIONING'); + }); + }); + + it('should not allow an out of bounds timeout', function() { + vm.waitFor(VALID_STATUSES[0], { timeout: -1 }, assert.ifError); + assert.strictEqual(vm.waiters.pop().timeout, 0); + + vm.waitFor(VALID_STATUSES[0], { timeout: 601 }, assert.ifError); + assert.strictEqual(vm.waiters.pop().timeout, 600); + }); + + it('should create a waiter', function(done) { + var now = new Date() / 1000; + vm.waitFor(VALID_STATUSES[0], done); + + var createdWaiter = vm.waiters.pop(); + + assert.strictEqual(createdWaiter.status, VALID_STATUSES[0]); + assert.strictEqual(createdWaiter.timeout, 300); + + assert(createdWaiter.startTime > now - 1000); + assert(createdWaiter.startTime < now + 1000); + + createdWaiter.callback(); // done() + }); + + it('should flip hasActiveWaiters to true', function() { + assert.strictEqual(vm.hasActiveWaiters, false); + vm.waitFor(VALID_STATUSES[0], assert.ifError); + assert.strictEqual(vm.hasActiveWaiters, true); + }); + + it('should start polling', function(done) { + vm.startPolling_ = done; + + vm.waitFor(VALID_STATUSES[0], assert.ifError); + }); + + it('should not start polling if already polling', function(done) { + vm.hasActiveWaiters = true; + + vm.startPolling_ = function() { + done(new Error('Should not have called startPolling_.')); + }; + + vm.waitFor(VALID_STATUSES[0], assert.ifError); + done(); + }); + }); + + describe('startPolling_', function() { + var METADATA = {}; + + beforeEach(function() { + vm.hasActiveWaiters = true; + + vm.getMetadata = function(callback) { + callback(null, METADATA); + }; + }); + + it('should only poll if there are active waiters', function(done) { + vm.hasActiveWaiters = false; + + vm.getMetadata = function() { + done(new Error('Should not have refreshed metadata.')); + }; + + vm.startPolling_(); + done(); + }); + + it('should refresh metadata', function(done) { + vm.getMetadata = function() { + done(); + }; + + vm.startPolling_(); + }); + + describe('metadata refresh error', function() { + var ERROR = new Error('Error.'); + + beforeEach(function() { + vm.getMetadata = function(callback) { + callback(ERROR); + }; + + vm.waiters.push({ + callback: util.noop + }); + }); + + it('should execute waiter with error', function(done) { + vm.waiters[0].callback = function(err) { + assert.strictEqual(err, ERROR); + done(); + }; + + vm.startPolling_(); + }); + + it('should remove waiter', function() { + assert.strictEqual(vm.waiters.length, 1); + vm.startPolling_(); + assert.strictEqual(vm.waiters.length, 0); + }); + + it('should flip hasActiveWaiters to false', function() { + assert.strictEqual(vm.hasActiveWaiters, true); + vm.startPolling_(); + assert.strictEqual(vm.hasActiveWaiters, false); + }); + }); + + describe('desired status reached', function() { + var STATUS = 'status'; + var METADATA = { + status: STATUS + }; + + beforeEach(function() { + vm.getMetadata = function(callback) { + callback(null, METADATA); + }; + + vm.waiters.push({ + status: STATUS, + callback: util.noop + }); + }); + + it('should execute callback with metadata', function(done) { + vm.waiters[0].callback = function(err, metadata) { + assert.ifError(err); + assert.strictEqual(metadata, METADATA); + done(); + }; + + vm.startPolling_(); + }); + + it('should remove waiter', function() { + assert.strictEqual(vm.waiters.length, 1); + vm.startPolling_(); + assert.strictEqual(vm.waiters.length, 0); + }); + + it('should flip hasActiveWaiters to false', function() { + assert.strictEqual(vm.hasActiveWaiters, true); + vm.startPolling_(); + assert.strictEqual(vm.hasActiveWaiters, false); + }); + }); + + describe('timeout exceeded', function() { + var STATUS = 'status'; + + beforeEach(function() { + vm.waiters.push({ + status: STATUS, + startTime: Date.now() / 1000 - 20, + timeout: 10, + callback: util.noop + }); + }); + + it('should execute callback with WaitForTimeoutError', function(done) { + vm.waiters[0].callback = function(err) { + assert.strictEqual(err.name, 'WaitForTimeoutError'); + assert.strictEqual(err.message, [ + 'waitFor timed out waiting for VM ' + vm.name, + 'to be in status: ' + STATUS + ].join(' ')); + + done(); + }; + + vm.startPolling_(); + }); + + it('should remove waiter', function() { + assert.strictEqual(vm.waiters.length, 1); + vm.startPolling_(); + assert.strictEqual(vm.waiters.length, 0); + }); + + it('should flip hasActiveWaiters to false', function() { + assert.strictEqual(vm.hasActiveWaiters, true); + vm.startPolling_(); + assert.strictEqual(vm.hasActiveWaiters, false); + }); + }); + + describe('desired status not reached yet', function() { + var STATUS = 'status'; + var setTimeout = global.setTimeout; + + beforeEach(function() { + vm.waiters.push({ + status: STATUS, + startTime: Date.now() / 1000, + timeout: 500 + }); + }); + + after(function() { + global.setTimeout = setTimeout; + }); + + it('should check for the status again after interval', function(done) { + global.setTimeout = function(fn, interval) { + assert.strictEqual(interval, 2000); + + vm.getMetadata = function() { + // Confirms startPolling_() was called again. + done(); + }; + + fn(); // startPolling_() + }; + + assert.strictEqual(vm.waiters.length, 1); + vm.startPolling_(); + assert.strictEqual(vm.waiters.length, 1); + }); + }); + }); + describe('request', function() { it('should make the correct request to Compute', function(done) { var reqOpts = {};