testarmada-magellan
Version:
Massively parallel automated testing
307 lines (268 loc) • 10.6 kB
JavaScript
;
var util = require("util");
var BaseWorkerAllocator = require("../worker_allocator");
var _ = require("lodash");
var request = require("request");
var sauceSettings = require("./settings");
var settings = require("../settings");
var analytics = require("../global_analytics");
var guid = require("../util/guid");
var tunnel = require("./tunnel");
var BASE_SELENIUM_PORT_OFFSET = 56000;
var SECOND_MS = 1000;
var SECONDS_MINUTE = 60;
// Allow polling to stall for 5 minutes. This means we can have a locks server
// outage of 5 minutes or we can have the server return errors for that period
// of time before Magellan gives up and fails a test for infrastructure reasons.
var VM_POLLING_MAX_TIME = sauceSettings.locksOutageTimeout;
var VM_POLLING_INTERVAL = sauceSettings.locksPollingInterval;
var VM_REQUEST_TIMEOUT = sauceSettings.locksRequestTimeout;
function SauceWorkerAllocator(_MAX_WORKERS) {
BaseWorkerAllocator.call(this, _MAX_WORKERS);
this.tunnels = [];
this.tunnelErrors = [];
this.MAX_WORKERS = _MAX_WORKERS;
this.maxTunnels = sauceSettings.maxTunnels;
this.tunnelPrefix = guid();
if (sauceSettings.locksServerLocation) {
console.log("Using locks server at " + sauceSettings.locksServerLocation
+ " for VM traffic control.");
}
}
util.inherits(SauceWorkerAllocator, BaseWorkerAllocator);
SauceWorkerAllocator.prototype.initialize = function (callback) {
this.initializeWorkers(this.MAX_WORKERS);
if (!sauceSettings.useTunnels && !sauceSettings.sauceTunnelId) {
return callback();
} else if (sauceSettings.sauceTunnelId) {
// Aoint test to a tunnel pool, no need to initialize tunnel
// TODO: verify if sauce connect pool is avaiable and if at least one
// tunnel in the pool is ready
this.tunnels.push({ name: "fake sc process" });
console.log("Connected to sauce tunnel pool with Tunnel ID", sauceSettings.sauceTunnelId);
this.assignTunnelsToWorkers(this.tunnels.length);
return callback();
} else {
tunnel.initialize(function (initErr) {
if (initErr) {
return callback(initErr);
} else {
analytics.push("sauce-open-tunnels");
this.openTunnels(function (openErr) {
if (openErr) {
analytics.mark("sauce-open-tunnels", "failed");
return callback(new Error("Cannot initialize worker allocator: " + openErr.toString()));
} else {
// NOTE: We wait until we know how many tunnels we actually got before
// we assign tunnel ids to workers.
analytics.mark("sauce-open-tunnels");
this.assignTunnelsToWorkers(this.tunnels.length);
return callback();
}
}.bind(this));
}
}.bind(this));
}
};
SauceWorkerAllocator.prototype.release = function (worker) {
var self = this;
if (sauceSettings.locksServerLocation) {
request({
method: "POST",
json: true,
timeout: VM_REQUEST_TIMEOUT,
body: {
token: worker.token
},
url: sauceSettings.locksServerLocation + "/release"
}, function () {
// TODO: decide whether we care about an error at this stage. We're releasing
// this worker whether the remote release is successful or not, since it will
// eventually be timed out by the locks server.
BaseWorkerAllocator.prototype.release.call(self, worker);
});
} else {
BaseWorkerAllocator.prototype.release.call(self, worker);
}
};
SauceWorkerAllocator.prototype.get = function (callback) {
var self = this;
//
// http://0.0.0.0:3000/claim
//
// {"accepted":false,"message":"Claim rejected. No VMs available."}
// {"accepted":true,"token":null,"message":"Claim accepted"}
//
if (sauceSettings.locksServerLocation) {
var pollingStartTime = Date.now();
// Poll the worker allocator until we have a known-good port, then run this test
var poll = function () {
if (settings.debug) {
console.log("asking for VM..");
}
request.post({
url: sauceSettings.locksServerLocation + "/claim",
timeout: VM_REQUEST_TIMEOUT,
form: {}
}, function (error, response, body) {
try {
if (error) {
throw new Error(error);
}
var result = JSON.parse(body);
if (result) {
if (result.accepted) {
if (settings.debug) {
console.log("VM claim accepted, token: " + result.token);
}
BaseWorkerAllocator.prototype.get.call(self, function (getWorkerError, worker) {
if (worker) {
worker.token = result.token;
}
callback(getWorkerError, worker);
});
} else {
if (settings.debug) {
console.log("VM claim not accepted, waiting to try again ..");
}
// If we didn't get a worker, try again
setTimeout(poll, VM_POLLING_INTERVAL);
}
} else {
throw new Error("Result from locks server is invalid or empty: '" + result + "'");
}
} catch (e) {
// NOTE: There are several errors that can happen in the above code:
//
// 1. Parsing - we got a response from locks, but it's malformed
// 2. Interpretation - we could parse a result, but it's empty or weird
// 3. Connection - we attempted to connect, but timed out, 404'd, etc.
//
// All of the above errors end up here so that we can indiscriminately
// choose to tolerate all types of errors until we've waited too long.
// This allows for the locks server to be in a bad state (whether due
// to restart, failure, network outage, or whatever) for some amount of
// time before we panic and start failing tests due to an outage.
if (Date.now() - pollingStartTime > VM_POLLING_MAX_TIME) {
// we've been polling for too long. Bail!
return callback(new Error("Gave up trying to get "
+ "a saucelabs VM from locks server. " + e));
} else {
if (settings.debug) {
console.log("Error from locks server, tolerating error and"
+ " waiting " + VM_POLLING_INTERVAL + "ms before trying again");
}
setTimeout(poll, VM_POLLING_INTERVAL);
}
}
});
};
poll();
} else {
BaseWorkerAllocator.prototype.get.call(this, callback);
}
};
SauceWorkerAllocator.prototype.assignTunnelsToWorkers = function (numOpenedTunnels) {
var self = this;
// Assign a tunnel id for each worker.
this.workers.forEach(function (worker, i) {
worker.tunnelId = self.getTunnelId(i % numOpenedTunnels);
console.log("Assigning worker " + worker.index + " to tunnel " + worker.tunnelId);
});
};
SauceWorkerAllocator.prototype.getTunnelId = function (tunnelIndex) {
if (sauceSettings.sauceTunnelId) {
// if sauce tunnel id exists
return sauceSettings.sauceTunnelId;
} else {
return this.tunnelPrefix + "_" + tunnelIndex;
}
};
SauceWorkerAllocator.prototype.teardown = function (callback) {
if (sauceSettings.useTunnels) {
this.teardownTunnels(callback);
} else {
return callback();
}
};
SauceWorkerAllocator.prototype.openTunnels = function (callback) {
var self = this;
var tunnelOpened = function (err, tunnelInfo) {
if (err) {
self.tunnelErrors.push(err);
} else {
self.tunnels.push(tunnelInfo);
}
if (self.tunnels.length === self.maxTunnels) {
console.log("All tunnels open! Continuing...");
return callback();
} else if (self.tunnels.length > 0
&& self.tunnels.length + self.tunnelErrors.length === self.maxTunnels) {
// We've accumulated some tunnels and some errors. Continue with a limited number of workers?
console.log("Opened only " + self.tunnels.length + " tunnels out of "
+ self.maxTunnels + " requested (due to errors).");
console.log("Continuing with a reduced number of workers ("
+ self.tunnels.length + ").");
return callback();
} else if (self.tunnelErrors.length === self.maxTunnels) {
// We've tried to open N tunnels but instead got N errors.
return callback(new Error("\nCould not open any sauce tunnels (attempted to open "
+ self.maxTunnels + " total tunnels): \n" +
self.tunnelErrors.map(function (tunnelErr) {
return tunnelErr.toString();
}).join("\n") + "\nPlease check that there are no "
+ "sauce-connect-launcher (sc) processes running."
));
} else {
if (err) {
console.log("Failed to open a tunnel, number of failed tunnels: "
+ self.tunnelErrors.length);
}
console.log(self.tunnels.length + " of " + self.maxTunnels + " tunnels open. Waiting...");
}
};
var openTunnel = function (tunnelIndex) {
var tunnelId = self.getTunnelId(tunnelIndex);
console.log("Opening tunnel " + tunnelIndex + " of "
+ self.maxTunnels + " [id = " + tunnelId + "]");
var options = {
tunnelId: tunnelId,
seleniumPort: BASE_SELENIUM_PORT_OFFSET + (tunnelIndex + 1),
callback: tunnelOpened
};
tunnel.open(options);
};
_.times(this.maxTunnels, function (n) {
// worker numbers are 1-indexed
console.log("Waiting " + n + " sec to open tunnel #" + n);
_.delay(function () {
openTunnel(n);
}, n * SECOND_MS);
});
};
SauceWorkerAllocator.prototype.teardownTunnels = function (callback) {
var self = this;
var tunnelsOriginallyOpen = this.tunnels.length;
var tunnelsOpen = this.tunnels.length;
var tunnelCloseTimeout = (sauceSettings.tunnelTimeout || SECONDS_MINUTE) * SECOND_MS;
var closeTimer = setTimeout(function () {
// NOTE: We *used to* forcefully clean up stuck tunnels in here, but instead,
// we now leave the tunnel processes for process_cleanup to clean up.
console.log("Timeout reached waiting for tunnels to close... Continuing...");
return callback();
}, tunnelCloseTimeout);
var tunnelClosed = function () {
if (--tunnelsOpen === 0) {
console.log("All tunnels closed! Continuing...");
clearTimeout(closeTimer);
return callback();
} else {
console.log(tunnelsOpen + " of " + tunnelsOriginallyOpen
+ " tunnels still open... waiting...");
}
};
_.each(self.tunnels, function (tunnelInfo) {
tunnel.close(tunnelInfo, tunnelClosed);
});
};
module.exports = SauceWorkerAllocator;