CloudMachineTest.js

const IO = require("./IO");
const TestMachine = require("./TestMachine");
const TestError = require("./TestError");

/**
 * High-level testing infrastructure for testing cloud machine images.
*/
class CloudMachineTest {

  constructor() {
    this.maxInstances = 10;
    this.maxInstanceAge = 4 * 60 * 60 * 1000; // 4 hours

    this.exitCode = 0;
    this.io = new IO();
    this.machine = new TestMachine();
  }

  /* ***************  Test Organization *************** */

  /**
   * The highest-level organization of testing.
   * @return {Promise} resolves when the test has completed.
  */
  test() {
    return this
      .prepareEnvironment()
      .then(() => this.setup())
      .then(() => this.runTests())
      .then(() => this.manualSteps())
      .catch(err => {
        if(this.exitCode === 0) { this.exitCode = 1; }
        console.log(err);
        return true;
      })
      .then(() => this.cleanup())
      .catch(err => {
        if(this.exitCode === 0) { this.exitCode = 1; }
        console.log(err);
        return true;
      })
      .then(() => {
        console.log(`Exiting with code ${this.exitCode}`);
        process.exit(this.exitCode);
      });
  }

  /**
   * Ensures that the test is able to run: ensures that credentials exist and are valid, that test servers have been
   * cleaned up, and cleans up files from previous runs.
   * @return {Promise} resolves when the environment has been prepared.
  */
  prepareEnvironment() {
    return this
      .ensureCredentials()
      .then(() => this.preventRunawayServers());
  }

  /**
   * Prepares the test environment.
   * @return {Promise} resolves when the environment has been created.
  */
  setup() {
    return this.createInstance();
  }

  /**
   * Executes automatic tests in {@link Test#automatedTests}, if requested by the user.
   * @return {Promise} resolves when automated testing is finished.
  */
  runTests() {
    return this.io
      .yesNo("Run automated tests?", "Y", "Y")
      .then(answer => {
        if(answer) {
          return this.automatedTests();
        }
      });
  }

  /**
   * The automated tests to run on the created machine.  Override with your own tests.
   * @return {Promise} resolves after all tests have been run.
  */
  automatedTests() {
    return Promise.resolve();
  }

  /**
   * Adds manual actions that can be taken while running tests.
   * @return {Promise} resolves when actions have been completed.
  */
  manualSteps() {
    return this.manualSSH();
  }

  /**
   * Cleans up artifacts from running the test.
   * @return {Promise} resolves when artifacts have been cleaned up.
  */
  cleanup() {
    return this.destroyInstance();
  }

  /* ***************   Step Execution  **************** */

  /**
   * Ensure we have credentials and permissions to execute all commands needed for testing.
   * @todo Implement method.
   * @return {Promise} resolves after credentials and permissions have been checked.
  */
  ensureCredentials() {
    return Promise.resolve();
  }

  /**
   * Checks to ensure that machine instances are cleaned up after testing, both by using a hard limit for the total
   * number of machines, as well as checking if any instances have been alive for a long period of time.
   * @return {Promise} resolves after runaway servers have been checked.
 */
  preventRunawayServers() {
    let instances;
    return this.machine
      .getInstances()
      .then(i => instances = i)
      .then(() => {
        let current = instances.length;
        this.io.status(`${current} machine instances already running.`);
        if(current > this.maxInstances) {
          return this.io
            .yesNo("Might have runaway servers.  Create another?", "Y", "N")
            .then(answer => {
              if(!answer) { throw new TestError("Runaway servers detected."); }
            });
        }
      })
      .then(() => {
        const old = instances.filter(i => new Date() - new Date(i.creationTimestamp) > this.maxInstanceAge);
        let desc;
        const minute = 60 * 1000;
        const hour = 60 * minute;
        const day = 24 * hour;
        if(this.maxInstanceAge > day) { desc = `${Math.floor(this.maxInstanceAge / day)} days`; }
        else if(this.maxInstanceAge > hour) { desc = `${Math.floor(this.maxInstanceAge / hour)} hours`; }
        else if(this.maxInstanceAge > minute) { desc = `${Math.floor(this.maxInstanceAge / minute)} minutes`; }
        else { desc = `${this.maxInstanceAge}ms`; }
        this.io.status(`${old.length} machine instances have been running for longer than ${desc}`);
        if(old.length > 0) {
          return this.io
            .yesNo("Might have runaway servers.  Create another?", "Y", "N")
            .then(answer => {
              if(!answer) { throw new TestError("Runaway servers detected."); }
            });
        }
      });
  }

  /**
   * Create a new machine instance.
   * @return {Promise} resolves when the new instance has been created.
  */
  createInstance() {
    this.io.status("Creating a new machine instance.");
    return this.machine.createInstance();
  }

  /**
   * Offer the user the option of SSHing into the machine instance to manually test or debug.
   * @return {Promise} resolves when the user is finished with any manual SSH tasks.
  */
  manualSSH() {
    return this.io
      .yesNo("SSH into running machine?", "N", "N")
      .then(answer => {
        if(answer) {
          return this.machine
            .ssh()
            .then(() => this.manualSSH());
        }
      });
  }

  /**
   * Stop the machine instance.
   * @return {Promise} resolves when the instance has been destroyed.
   * @todo Allow running destruction in background.
  */
  destroyInstance() {
    return this.io
      .yesNo("Destroy the machine instance?", "Y", "Y")
      .then(answer => {
        if(answer) {
          this.io.status("Destroying the machine instance.");
          return this.machine.destroyInstance();
        }
      });
  }

}

module.exports = CloudMachineTest;