azukiapp/azk

View on GitHub
src/agent/vm.js

Summary

Maintainability
D
2 days
Test Coverage
import { _, path, config, log, isBlank, fsAsync } from 'azk';
import { subscribe, publish } from 'azk/utils/postal';
import { async, promisify, thenAll, promisifyModule } from 'azk/utils/promises';
import Utils from 'azk/utils';
import { Tools } from 'azk/agent/tools';
import { SSH } from 'azk/agent/ssh';

var vbm  = require('vboxmanage');

var machine  = promisifyModule(vbm.machine );
var instance = promisifyModule(vbm.instance);
var hostonly = promisifyModule(vbm.hostonly);
var dhcp     = promisifyModule(vbm.dhcp    );
var _exec    = promisify(vbm.command.exec, { multiArgs: true, context: vbm.command });

function exec(...args) {
  return _exec(...args).then((result) => {
    if (result[0] !== 0) {
      result[1] = "command: " + args.join(' ') + "\n\n" + result[1];
      throw new Error(result[1]);
    }
    return result[1];
  });
}

function modifyvm(name, ...options) {
  return exec("modifyvm", name, ...options);
}

var guestproperty = {
  set(vm_name, property, value, flags = null) {
    var args = ["guestproperty", "set", vm_name, property, value];
    if (_.isArray(flags)) {
      flags = flags.join(',');
    }
    if (_.isString(flags)) {
      args.push("--flags", flags);
    }
    return exec.apply(null, args);
  },

  get(vm_name, property) {
    return exec("guestproperty", "get", vm_name, property)
      .then((output) => {
        var result = null;
        if (!output.match(/No value set!/)) {
          result = vbm.parse.linebreak_list(output);
        }
        return _.isEmpty(result) ? {} : result[0];
      });
  },

  waitMatch: /^Name:\s*(.*?),\s*value:\s*(.*?),\s*flags:\s*(.*?)$/,

  wait(vm_name, property, timeout, fail = false) {
    var args = ["guestproperty", "wait", vm_name, property, "--timeout", timeout];
    if (fail) {
      args.push("--fail-on-timeout");
    }
    return exec.apply(null, args)
      .then((output) => {
        var match = output.trim().match(this.waitMatch);
        if (match) {
          return {
            Value: match[2],
            Flags: match[3].split(',').map((token) => token.trim())
          };
        }
        return {};
      });
  },
};

hostonly.getByName = function(name) {
  return this.list().then((list) => {
    return _.find(list, (net) => net.Name == name);
  });
};

dhcp.getByNetworkName = function(name) {
  return this.list_servers().then((list) => {
    return _.find(list, (server) => server.NetworkName == name);
  });
};

var hdds = {
  list() {
    return exec("list", "hdds").then((output) => {
      return vbm.parse.linebreak_list(output);
    });
  },

  close(file, remove) {
    var args = ["closemedium", "disk", file];
    if (remove) { args.push("--delete"); }
    return exec.apply(null, args);
  },

  clonehd(origin, target) {
    var self = this;
    return exec("clonehd", origin, target)
      .then(hdds.list)
      .then((hdds) => {
        var closes = [];
        _.each(hdds, (hdd) => {
          if (_.contains([origin, target], hdd.Location)) {
            closes.push(self.close(hdd.Location), hdd.Location == origin);
          }
        });
        return thenAll(closes);
      });
  },
};

function config_nat_interface(name, replace = false) {
  return async(function* () {
    var ssh_port  = yield Utils.net.getPort();
    var ssh_natpf = ["--natpf2", "ssh,tcp,127.0.0.1," + ssh_port + ",,22"];

    if (replace) {
      // Remove and add
      yield modifyvm(name, ["--natpf2", "delete", "ssh"]);
      yield modifyvm(name, ssh_natpf);
    } else {
      yield modifyvm(name, [
        "--nic2", "nat",
        "--nictype2", "virtio",
        "--cableconnected2", "on",
        ...ssh_natpf
      ]);
    }
  });
}

function config_dhcp(net, getway, net_mask, ip) {
  return async(function* () {
    var lower_ip = ip;
    var upper_ip = ip;
    yield dhcp.ensure_hostonly_server(net, getway, net_mask, lower_ip, upper_ip);
    yield dhcp.enable_hostonly_server(net);
  });
}

function config_net_interfaces(name, ip, use_dhcp) {
  return async(function* () {
    var result = yield exec("hostonlyif", "create");
    var inter  = result.match(/Interface '(.*)?'/)[1];

    yield modifyvm(name, [
      "--nic1", "hostonly",
      "--nictype1", "virtio",
      "--cableconnected1", "on",
      "--hostonlyadapter1", inter
    ]);

    // Configure dhcp server
    var gateway = Utils.net.calculateGatewayIp(ip);
    var network = Utils.net.calculateNetIp(ip);
    var netmask = "255.255.255.0";

    // nat interfance
    yield config_nat_interface(name);
    yield hostonly.configure_if(inter, gateway, netmask);

    // dhcp server
    if (use_dhcp) {
      yield config_dhcp(inter, gateway, netmask, ip);
    } else {
      var key_base = "/VirtualBox/D2D/eth0";
      return thenAll([
        guestproperty.set(name, `${key_base}/address`, ip),
        guestproperty.set(name, `${key_base}/netmask`, netmask),
        guestproperty.set(name, `${key_base}/network`, network),
      ]);
    }
  });
}

function config_share(name) {
  return async(this, function* () {
    yield exec(
      "sharedfolder", "add", name,
      "--name", "Root",
      "--hostpath", "/"
    );

    yield exec(
      "setextradata", name,
      "VBoxInternal2/SharedFoldersEnableSymlinksCreate/Root",
      "1"
    );
  });
}

function config_disks(name, boot, data) {
  var use_link = true;

  var storage_opts = [
    "storagectl"   , name  ,
    "--name"       , "SATA",
    "--add"        , "sata",
    "--hostiocache", "on"  ,
  ];

  var storage_boot = [
    "storageattach", name  ,
    "--storagectl" , "SATA",
    "--port"       , "0"   ,
    "--device"     , "0"   ,
    "--type"       , "dvddrive",
    "--medium"     , boot  ,
  ];

  var data_link   = `${data}.link`;
  var data_origin = path.join("./", path.basename(data));
  var storage_data = [
    "storageattach", name  ,
    "--storagectl" , "SATA",
    "--port"       , "1"   ,
    "--device"     , "0"   ,
    "--type"       , "hdd" ,
    "--medium"     , use_link ? data_link : data,
  ];

  return async(function* () {
    if (!(yield fsAsync.exists(data))) {
      var file   = data + ".tmp";
      var origin = config("agent:vm:blank_disk");
      yield Utils.unzip(origin, file).catch((err) => {
        throw new Error('Invalid disk file ' + origin + ', err: ' + err);
      });
      yield hdds.clonehd(file, data);
    }

    if (use_link) {
      yield fsAsync.remove(data_link).catch(() => {});
      yield fsAsync.symlink(data_origin, data_link, 'file');
    }

    yield exec.apply(null, storage_opts);
    yield exec.apply(null, storage_boot);
    yield exec.apply(null, storage_data);
  });
}

function acpipowerbutton(name) {
  return exec('controlvm', name, 'acpipowerbutton');
}

var vm = {
  version() {
    return exec("--version");
  },

  info(vm_name) {
    return machine.info(vm_name).then((info) => {
      if (info['Forwarding(0)']) {
        var port = info['Forwarding(0)'].replace(/ssh,tcp,127.0.0.1,(.*),,22/, '$1');
        if (port) {
          info.ssh_port = port;
        }
      }
      return _.merge(info, { installed: true, running: info.VMState == "running" });
    }, (err) => {
      if (err.message.match(/cannot show vm info/)) {
        return { installed: false, running: false };
      }
      throw err;
    });
  },

  init(opts = {}) {
    opts = _.defaults(opts, {
      dhcp: false,
    });

    return Tools.async_status("vm", this, function* (status_change) {
      var name = opts.name;
      if (yield this.isInstalled(name)) {
        return false;
      }

      status_change("installing");
      yield exec("createvm", "--name", name, "--register");

      var cmd = [
        "--ostype", "Linux26_64",
        "--cpus", config("agent:vm:cpus"),
        "--memory", config("agent:vm:memory"),
        "--vram", "9",
        "--rtcuseutc", "on",
        "--acpi", "on",
        "--ioapic", "on",
        "--hpet", "on",
        "--hwvirtex", "on",
        "--vtxvpid", "on",
        "--largepages", "on",
        "--nestedpaging", "on",
        "--firmware", "bios",
        "--bioslogofadein", "off",
        "--bioslogofadeout", "off",
        "--bioslogodisplaytime", "0",
        "--biosbootmenu", "disabled",
        "--boot1", "dvd",
      ];

      var usage = yield _exec("modifyvm");
      if (usage.join("\n").match(/--vtxux/)) {
        cmd.push('--vtxux', 'on');
      }

      yield modifyvm(name, cmd);
      yield config_net_interfaces(name, opts.ip, opts.dhcp);
      yield config_disks(name, opts.boot, opts.data);
      yield config_share(name);

      status_change("installed");

      return yield this.info(name);
    });
  },

  rename(old_name, new_name) {
    return this.isInstalled(old_name).then((installed) => {
      if (installed) {
        return modifyvm(old_name, ['--name', new_name]);
      }
    });
  },

  isInstalled(vm_name) {
    return this.info(vm_name).then((status) => {
      return status.installed;
    });
  },

  isRunnig(vm_name) {
    return this.info(vm_name).then((status) => {
      return status.running;
    });
  },

  // TODO: Move install to start
  start(vm_name, wait = false) {
    log.debug("[vm] call to start vm %s", vm_name);
    return Tools.async_status("vm", this, function* (status_change) {
      var info = yield vm.info(vm_name);

      if (info.installed && !(info.running)) {
        status_change("starting");
        // Reconfigures the interface nat all times
        yield config_nat_interface(vm_name, true);
        return instance.start(vm_name).then(() => {
          if (wait) {
            return this.waitReady(vm_name, wait);
          } else {
            status_change("started");
            return true;
          }
        });
      }
      return false;
    });
  },

  getProperty(...args) {
    return guestproperty.get(...args);
  },

  setProperty(...args) {
    return guestproperty.set(...args);
  },

  saveScreenShot(vm_name) {
    return async(this, function* () {
      var info = yield vm.info(vm_name);
      if (info.installed && info.running) {
        var dir  = config("agent:vm:screen_path");
        var file = path.join(dir, `${(new Date()).getTime()}.png`);
        yield fsAsync.mkdirs(dir);
        yield exec('controlvm', vm_name, 'screenshotpng', file);
        return file;
      }
      return null;
    });
  },

  waitReady(vm_name, timeout) {
    log.debug("[vm] waiting for the vm `%s` becomes available", vm_name);
    return Tools.async_status("vm", this, function* (status_change) {
      var info = yield vm.info(vm_name);
      var key  = "/VirtualBox/D2D/Done";

      if (info.installed && info.running) {
        var status = yield guestproperty.get(vm_name, key);
        if (status.Value !== "true") {
          status_change("waiting");
          status = yield guestproperty.wait(vm_name, key, timeout, false);
          if (status.Value === "true") {
            status_change("ready");
            return true;
          }
        } else {
          return true;
        }
      }

      return false;
    });
  },

  // TODO: Add treatment to when the lock virtualbox
  stop(vm_name, force = false, timeout = 30) {
    log.debug("[vm] call to stop vm %s", vm_name);

    return Tools.async_status("vm", this, function* (status_change) {
      var info = yield vm.info(vm_name);
      if (info.running) {
        status_change("stopping");

        var hrend, hrstart = process.hrtime();
        if (force) {
          yield instance.stop(vm_name);
        } else {
          yield acpipowerbutton(vm_name);
        }

        // Wait for shutdown
        while (true) {
          info  = yield this.info(vm_name);
          hrend = process.hrtime(hrstart);
          // Force after timeout
          if ((!force) && info.running && hrend[0] > timeout) {
            force = true;
            status_change("forced");
            yield instance.stop(vm_name);
          } else if (!info.running) {
            break;
          }
        }

        status_change("stopped");
        return true;
      }
      return false;
    });
  },

  remove(vm_name) {
    return Tools.async_status("vm", this, function* (status_change) {
      var info = yield vm.info(vm_name);

      if (info.name == vm_name) {
        status_change("removing");

        // Removing disk if it's not a link (old disk style)
        var disk_file = info['SATA-1-0'];
        if (!_.isEmpty(disk_file)) {

          var is_link = yield fsAsync.lstat(disk_file);

          if (!is_link.isSymbolicLink()) {
            yield exec("storagectl", vm_name, "--name", "SATA", "--remove");
            yield exec("closemedium", "disk", disk_file);
          }
        }

        // Remove networking interface
        if (!isBlank(info.hostonlyadapter1)) {
          var net    = yield hostonly.getByName(info.hostonlyadapter1);
          if (!_.isEmpty(net)) {
            var server = yield dhcp.getByNetworkName(net.VBoxNetworkName);

            if (!_.isEmpty(server)) {
              yield dhcp.remove_hostonly_server(info.hostonlyadapter1);
            }
            yield hostonly.remove_if(info.hostonlyadapter1);
          }
        }

        // Remove vm
        yield machine.remove(vm_name);

        status_change("removed");
      }
    });
  },

  make_ssh(vm_name) {
    return async(this, function* () {
      var info = yield this.info(vm_name);
      if (info.running) {
        return new SSH('127.0.0.1', info.ssh_port);
      } else {
        throw new Error("vm is not running");
      }
    });
  },

  ssh(name, cmd, wait = false) {
    return this.make_ssh(name).then((ssh) => { return ssh.exec(cmd, wait); });
  },

  copyFile(name, origin, target) {
    return this.make_ssh(name).then((ssh) => { return ssh.putFile(origin, target); });
  },

  copyVMFile(name, origin, target) {
    return this.make_ssh(name).then((ssh) => { return ssh.getFile(origin, target); });
  },

  mount(vm_name, share, point, opts = {}) {
    _.defaults(opts, {
      umask: "0000",
      gid  : "vboxsf",
      uid  : config("agent:vm:user"),
    });

    // object to array of the key=value
    opts = _.reduce(opts, (acc, value, key) => {
      acc.push(`${key}=${value}`); return acc;
    }, []);

    var mount = `sudo mount -t vboxsf -o ${opts.join(',')} ${share} ${point}`;
    var check = `mount | grep "${point}\\s" &>/dev/null`;
    var cmd   = [
      `if sudo modprobe vboxguest &> /dev/null && sudo modprobe vboxsf &> /dev/null; then`,
      `  [ -d "${point}" ] || { sudo mkdir -p ${point}; } ;`,
      "  { " + check + " || " + mount + "; } ;",
      "fi"
    ].join(" ");

    var stderr = "";

    var _subscription = subscribe('agent.vm.ssh.status', (event) => {
      if (event.type == "ssh" && event.context == "stderr") {
        stderr += event.data.toString();
      }
      publish('agent.vm.mount.status', event);
    });

    return VM.ssh(vm_name, cmd).then((code) => {
      if (code !== 0) {
        throw new Error('not mount share files, error:\n' + stderr);
      }
      _subscription.unsubscribe();
    })
    .catch(function (err) {
      _subscription.unsubscribe();
      throw err;
    });
  },
};

var VM = vm;
export { VM, dhcp, hostonly };