TestMachine.js

const merge = require("lodash.merge");
const promiseTimeout = require("promise-timeout").timeout;
const sleep = require("promise.delay");
const TestError = require("./TestError");

/**
 * @typedef {Object} TestMachineOptions
 * @property {String} prefix inserted before the name of generated machines.  Defaults to `test-`.
 * @property {Number} sshTimeout the duration to wait for new SSH connections to complete, in milliseconds.
 *   Defaults to 2 minutes.
 * @property {null|String} sshUser if provided, specifies a remote user to connect to.  If in CI, defaults to `ci`.
 *   otherwise not provided (and uses a default).
*/

/**
 * Abstract class that creates machines, runs tests on created machines, then destroys the machines.
*/
class TestMachine {

  /**
   * @param {TestMachineOptions} opts options to configure the test machine.
  */
  constructor(opts) {
    this.opts = merge({}, {
      prefix: "test-",
      sshTimeout: 2 * 60 * 1000,
      sshUser: process.env.CI ? "ci" : undefined,
    }, opts);
  }

  /**
   * Fetches all machines that are currently running created by this testing.
   * @return {Promise<Array>} resolves to the current running machines.  Each includes
   *   `{ id, name, zone, creationTimestamp }`.
   * @abstract
  */
  getInstances() {
    return Promise.resolve([]);
  }

  /**
   * Creates a new machine for testing.
   * @return {Promise} resolves when the machine has been created.
   * @abstract
  */
  createInstance() {
    return Promise.reject(new Error("Abstract TestMachine#createInstance not implemented."));
  }

  /**
   * Destroys the machine used for testing.
   * @return {Promise} resolves when the machine has been destroyed.
   * @abstract
  */
  destroyInstance() {
    return Promise.reject(new Error("Abstract TestMachine#destroyInstance not implemented."));
  }

  /*eslint-disable no-unused-vars*/
  /**
   * Runs an SSH connection to the current testing machine.
   * @param {String} command a command to execute on the remote machine.  If not provided, an interactive SSH session
   *   is started.
   * @return {Promise} resolves when the SSH connection terminates.
   * @abstract
  */
  ssh(command) {
    return Promise.reject(new Error("Abstract TestMachine#destroyInstance not implemented."));
  }
  /*eslint-enable no-unused-vars*/

  /**
   * Ensure that a newly created machine has started.
   * @param {Integer} timeout the maxiumum time to wait.
   * @param {Integer} [started] the timestamp of the first check executed.  Defaults to the current time.
   * @param {Integer} [failures] the number of times that an SSH connection has been tried.  Defaults to `0`.
   * @return {Promise} resolves when the machine has been started.
  */
  ensureStarted(timeout, started, failures) {
    if(!started) { started = new Date(); }
    if(!failures) { failures = 0; }
    if(new Date() - started > timeout) {
      throw new TestError(`Machine has not responded to SSH in ${timeout}ms (Attempted ${failures} connections)`);
    }
    return promiseTimeout(this.ssh("echo 'Test'"), this.opts.sshTimeout)
      .then(() => console.log(`Machine responded to SSH connections in ${new Date() - started}ms`))
      .catch(() => sleep(100).then(() => this.ensureStarted(timeout, started, ++failures)));
  }

  /**
   * Ensure that a `systemd` service is running.
   * @param {String} service the name of the `systemd` service to check.
   * @param {Integer} timeout the maxiumum time to wait.
   * @param {Integer} [started] the timestamp of the first check executed.  Defaults to the current time.
   * @return {Promise} resolves when the session is running.
  */
  ensureServiceStarted(service, timeout, started) {
    if(!started) { started = new Date(); }
    if(new Date() - started > timeout) {
      throw new TestError(`Service ${service} has not started after ${timeout}ms.`);
    }
    return this
      .ssh(`systemctl is-active ${service}`)
      .catch(() => sleep(100).then(() => this.ensureServiceStarted(service, timeout, started)));
  }

}

module.exports = TestMachine;