zodern/meteor-up

View on GitHub
src/plugins/meteor/command-handlers.js

Summary

Maintainability
F
3 days
Test Coverage
import {
  addStartAppTask,
  checkAppStarted,
  createEnv,
  createServiceConfig,
  currentImageTag,
  escapeEnvQuotes,
  getImagePrefix,
  getNodeVersion,
  getSessions,
  shouldRebuild
} from './utils';
import buildApp, { archiveApp, cleanBuildDir } from './build.js';
import { checkUrls, createPortInfoLines, displayAvailability, getInformation, withColor } from './status';
import { map, promisify } from 'bluebird';
import { prepareBundleLocally, prepareBundleSupported } from './prepare-bundle';
import debug from 'debug';
import nodemiral from '@zodern/nodemiral';


const log = debug('mup:module:meteor');

export async function logs(api) {
  log('exec => mup meteor logs');
  const {
    app
  } = api.getConfig();
  const swarmEnabled = api.swarmEnabled();

  if (!app) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  const args = api.getArgs();

  if (args[0] === 'meteor') {
    args.shift();
  }
  if (swarmEnabled) {
    args.unshift('service');
  }

  const sessions = await getSessions(api);

  return api.getDockerLogs(app.name, sessions, args, !swarmEnabled);
}

export function setup(api) {
  log('exec => mup meteor setup');
  const config = api.getConfig().app;

  if (!config) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  const list = nodemiral.taskList('Setup Meteor');

  list.executeScript('Setup Environment', {
    script: api.resolvePath(__dirname, 'assets/meteor-setup.sh'),
    vars: {
      name: config.name
    }
  });

  if (config.ssl && typeof config.ssl.autogenerate !== 'object') {
    const basePath = api.getBasePath();

    if (config.ssl.upload !== false) {
      list.executeScript('Cleaning up SSL Certificates', {
        script: api.resolvePath(__dirname, 'assets/ssl-cleanup.sh'),
        vars: {
          name: config.name
        }
      });
      list.copy('Copying SSL Certificate Bundle', {
        src: api.resolvePath(basePath, config.ssl.crt),
        dest: `/opt/${config.name}/config/bundle.crt`
      });

      list.copy('Copying SSL Private Key', {
        src: api.resolvePath(basePath, config.ssl.key),
        dest: `/opt/${config.name}/config/private.key`
      });
    }

    list.executeScript('Verifying SSL Configurations', {
      script: api.resolvePath(__dirname, 'assets/verify-ssl-config.sh'),
      vars: {
        name: config.name
      }

    });
  }

  const sessions = api.getSessions(['app']);

  return api.runTaskList(list, sessions, { verbose: api.verbose });
}

export async function build(api) {
  const config = api.getConfig().app;
  const appPath = api.resolvePath(api.getBasePath(), config.path);
  const buildOptions = config.buildOptions;

  const rebuild = shouldRebuild(api);

  if (rebuild && api.getOptions()['cached-build']) {
    console.log('Unable to use previous build. It doesn\'t exist.');
  } else if (!rebuild) {
    console.log('Not building app. Using build from previous deploy at');
    console.log(buildOptions.buildLocation);
  }

  if (rebuild) {
    if (buildOptions.cleanBuildLocation === true) {
      console.log('Cleaning Up Previous Builds');
      await cleanBuildDir(buildOptions.buildLocation);
    }
    console.log('Building App Bundle Locally');
    await buildApp(appPath, buildOptions, api.getVerbose(), api);
  }
}

export async function prepareBundle(api) {
  log('exec => mup meteor prepare-bundle');
  const {
    app: appConfig,
    privateDockerRegistry
  } = api.getConfig();

  if (!appConfig) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  if (!prepareBundleSupported(appConfig.docker)) {
    return;
  }

  const buildOptions = appConfig.buildOptions;
  const bundlePath = api.resolvePath(buildOptions.buildLocation, 'bundle.tar.gz');

  if (appConfig.docker.prepareBundleLocally) {
    return prepareBundleLocally(buildOptions.buildLocation, bundlePath, api);
  }

  const list = nodemiral.taskList('Prepare App Bundle');

  let tag = 'latest';

  if (api.swarmEnabled()) {
    const data = await api.getServerInfo();
    tag = currentImageTag(data, appConfig.name) + 1;
  }

  const nodeVersion = await getNodeVersion(bundlePath);

  list.executeScript('Prepare Bundle', {
    script: api.resolvePath(
      __dirname,
      'assets/prepare-bundle.sh'
    ),
    vars: {
      appName: appConfig.name,
      dockerImage: appConfig.docker.image,
      env: escapeEnvQuotes(appConfig.env),
      buildInstructions: appConfig.docker.buildInstructions || [],
      nodeVersion,
      stopApp: appConfig.docker.stopAppDuringPrepareBundle,
      useBuildKit: appConfig.docker.useBuildKit,
      tag,
      privateRegistry: privateDockerRegistry,
      imagePrefix: getImagePrefix(privateDockerRegistry)
    }
  });

  // After running Prepare Bundle, the list of images will be out of date
  api.serverInfoStale();

  let sessions = api.getSessions(['app']);
  if (privateDockerRegistry) {
    sessions = sessions.slice(0, 1);
  }

  return api.runTaskList(list, sessions, {
    series: true,
    verbose: api.verbose
  });
}

export async function push(api) {
  log('exec => mup meteor push');

  await api.runCommand('meteor.build');
  const {
    app: appConfig,
    privateDockerRegistry
  } = api.getConfig();

  if (!appConfig) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  const buildOptions = appConfig.buildOptions;

  const bundlePath = api.resolvePath(buildOptions.buildLocation, 'bundle.tar.gz');

  if (shouldRebuild(api)) {
    await promisify(archiveApp)(buildOptions.buildLocation, api);
  }

  if (appConfig.docker.prepareBundleLocally) {
    return api.runCommand('meteor.prepareBundle');
  }

  const list = nodemiral.taskList('Pushing Meteor App');

  list.copy('Pushing Meteor App Bundle to the Server', {
    src: bundlePath,
    dest: `/opt/${appConfig.name}/tmp/bundle.tar.gz`,
    progressBar: appConfig.enableUploadProgressBar
  });

  let sessions = api.getSessions(['app']);

  // If we are using a private registry,
  // we only need to build it once. All of the other servers
  // can pull the image from the registry instead of rebuilding it
  if (privateDockerRegistry) {
    sessions = sessions.slice(0, 1);
  }

  await api.runTaskList(list, sessions, {
    series: true,
    verbose: api.verbose
  });

  return api.runCommand('meteor.prepareBundle');
}

export function envconfig(api) {
  log('exec => mup meteor envconfig');
  const {
    servers,
    app,
    proxy,
    privateDockerRegistry
  } = api.getConfig();

  if (api.swarmEnabled()) {
    // The `start` command handles updating the environment
    // when swarm is enabled
    return;
  }

  let bindAddress = '0.0.0.0';

  if (!app) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  app.log = app.log || {
    opts: {
      'max-size': '100m',
      'max-file': 10
    }
  };

  app.nginx = app.nginx || {};

  if (app.docker && app.docker.bind) {
    bindAddress = app.docker.bind;
  }

  if (app.dockerImageFrontendServer) {
    app.docker.imageFrontendServer = app.dockerImageFrontendServer;
  }
  if (!app.docker.imageFrontendServer) {
    app.docker.imageFrontendServer = 'meteorhacks/mup-frontend-server';
  }

  if (app.ssl) {
    app.ssl.port = app.ssl.port || 443;
  }

  const startHostVars = {};

  Object.keys(app.servers).forEach(serverName => {
    const host = servers[serverName].host;
    const vars = {};
    if (app.servers[serverName].bind) {
      vars.bind = app.servers[serverName].bind;
    }
    if (app.servers[serverName].env && app.servers[serverName].env.PORT) {
      vars.port = app.servers[serverName].env.PORT;
    }
    startHostVars[host] = vars;
  });

  const list = nodemiral.taskList('Configuring App');

  list.copy('Pushing the Startup Script', {
    src: api.resolvePath(__dirname, 'assets/templates/start.sh'),
    dest: `/opt/${app.name}/config/start.sh`,
    hostVars: startHostVars,
    vars: {
      imagePrefix: getImagePrefix(privateDockerRegistry),
      appName: app.name,
      port: app.env.PORT || 80,
      bind: bindAddress,
      sslConfig: app.ssl,
      logConfig: app.log,
      volumes: app.volumes,
      docker: app.docker,
      proxyConfig: proxy,
      nginxClientUploadLimit: app.nginx.clientUploadLimit || '10M',
      privateRegistry: privateDockerRegistry
    }
  });

  const env = createEnv(app, api.getSettings());
  const hostVars = {};

  Object.keys(app.servers).forEach(key => {
    const host = servers[key].host;
    if (app.servers[key].env) {
      hostVars[host] = {
        env: {
          ...app.servers[key].env,
          // We treat the PORT specially and do not pass it to the container
          PORT: undefined
        }
      };
    }
    if (app.servers[key].settings) {
      const settings = JSON.stringify(api.getSettingsFromPath(
        app.servers[key].settings));

      if (hostVars[host]) {
        hostVars[host].env.METEOR_SETTINGS = settings;
      } else {
        hostVars[host] = { env: { METEOR_SETTINGS: settings } };
      }
    }
  });

  list.copy('Sending Environment Variables', {
    src: api.resolvePath(__dirname, 'assets/templates/env.list'),
    dest: `/opt/${app.name}/config/env.list`,
    hostVars,
    vars: {
      env: env || {},
      appName: app.name
    }
  });

  const sessions = api.getSessions(['app']);

  return api.runTaskList(list, sessions, {
    series: true,
    verbose: api.verbose
  });
}

export async function start(api) {
  log('exec => mup meteor start');
  const config = api.getConfig().app;
  const swarmEnabled = api.swarmEnabled();

  if (!config) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  const list = nodemiral.taskList('Start Meteor');

  if (swarmEnabled) {
    const currentService = await api.dockerServiceInfo(config.name);
    const serverInfo = await api.getServerInfo();
    const imageTag = currentImageTag(serverInfo, config.name);

    // TODO: make it work when the reverse proxy isn't enabled
    api.tasks.addCreateOrUpdateService(
      list,
      createServiceConfig(api, imageTag),
      currentService
    );
  } else {
    addStartAppTask(list, api);
    checkAppStarted(list, api);
  }

  const sessions = await getSessions(api);

  return api.runTaskList(list, sessions, {
    series: true,
    verbose: api.verbose
  });
}

export function deploy(api) {
  log('exec => mup meteor deploy');

  // validate settings and config before starting
  api.getSettings();
  const config = api.getConfig().app;

  if (!config) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  return api
    .runCommand('meteor.push')
    .then(() => api.runCommand('default.reconfig'));
}

export async function stop(api) {
  log('exec => mup meteor stop');
  const config = api.getConfig().app;
  const swarmEnabled = api.swarmEnabled();

  if (!config) {
    console.error('error: no configs found for meteor');
    process.exit(1);
  }

  const list = nodemiral.taskList('Stop Meteor');

  if (swarmEnabled) {
    api.tasks.addStopService(list, {
      name: config.name
    });
  } else {
    list.executeScript('Stop Meteor', {
      script: api.resolvePath(__dirname, 'assets/meteor-stop.sh'),
      vars: {
        appName: config.name
      }
    });
  }

  const sessions = await getSessions(api);

  return api.runTaskList(list, sessions, { verbose: api.verbose });
}

export async function restart(api) {
  const list = nodemiral.taskList('Restart Meteor');
  const {
    app: appConfig
  } = api.getConfig();
  const sessions = await getSessions(api);

  if (api.swarmEnabled()) {
    api.tasks.addRestartService(list, { name: appConfig.name });
  } else {
    list.executeScript('Stop Meteor', {
      script: api.resolvePath(__dirname, 'assets/meteor-stop.sh'),
      vars: {
        appName: appConfig.name
      }
    });
    addStartAppTask(list, api);
    checkAppStarted(list, api);
  }


  return api.runTaskList(list, sessions, {
    series: true,
    verbose: api.verbose
  });
}

export async function debugApp(api) {
  const {
    servers,
    app
  } = api.getConfig();
  let serverOption = api.getArgs()[2];

  // Check how many sessions are enabled. Usually is all servers,
  // but can be reduced by the `--servers` option
  const enabledSessions = api.getSessions(['app'])
    .filter(session => session);

  if (!(serverOption in app.servers)) {
    if (enabledSessions.length === 1) {
      const selectedHost = enabledSessions[0]._host;
      serverOption = Object.keys(app.servers).find(
        name => servers[name].host === selectedHost
      );
    } else {
      console.log('mup meteor debug <server>');
      console.log('Available servers are:\n', Object.keys(app.servers).join('\n '));
      process.exitCode = 1;

      return;
    }
  }

  const server = servers[serverOption];
  console.log(`Setting up to debug app running on ${serverOption}`);

  const {
    output
  } = await api.runSSHCommand(server, `docker exec -t ${app.name} sh -c 'kill -s USR1 $(pidof -s node)'`);

  // normally is blank, but if something went wrong
  // it will have the error message
  console.log(output);

  const {
    output: startOutput
  } = await api.runSSHCommand(server, `sudo docker rm -f meteor-debug; sudo docker run -d --name meteor-debug --network=container:${app.name} alpine/socat TCP-LISTEN:9228,fork TCP:127.0.0.1:9229`);
  if (api.getVerbose()) {
    console.log('output from starting meteor-debug', startOutput);
  }

  const {
    output: ipAddress
  } = await api.runSSHCommand(server, `sudo docker inspect --format="{{ range .NetworkSettings.Networks }} {{.IPAddress }} {{ end }}" ${app.name} | head -n 1`);

  if (api.getVerbose()) {
    console.log('container address', ipAddress);
  }

  const {
    output: startOutput2
  } = await api.runSSHCommand(server, `sudo docker rm -f meteor-debug-2; sudo docker run -d --name meteor-debug-2 -p 9227:9227 alpine/socat TCP-LISTEN:9227,fork TCP:${ipAddress.trim()}:9228`);

  if (api.getVerbose()) {
    console.log('output from starting meteor-debug-2', startOutput2);
  }

  let loggedConnection = false;

  api.forwardPort({
    server,
    localAddress: '0.0.0.0',
    localPort: 9229,
    remoteAddress: '127.0.0.1',
    remotePort: 9227,
    onError(error) {
      console.error(error);
    },
    onReady() {
      console.log('Connected to server');
      console.log('');
      console.log('Debugger listening on ws://127.0.0.1:9229');
      console.log('');
      console.log('To debug:');
      console.log('1. Open chrome://inspect in Chrome');
      console.log('2. Select "Open dedicated DevTools for Node"');
      console.log('3. Wait a minute while it connects and loads the app.');
      console.log('   When it is ready, the app\'s files will appear in the Sources tab');
      console.log('');
      console.log('Warning: Do not use breakpoints when debugging a production server.');
      console.log('They will pause your server when hit, causing it to not handle methods or subscriptions.');
      console.log('Use logpoints or something else that does not pause the server');
      console.log('');
      console.log('The debugger will be enabled until the next time the app is restarted,');
      console.log('though only accessible while this command is running');
    },
    onConnection() {
      if (!loggedConnection) {
        // It isn't guaranteed the debugger is connected, but not many
        // other tools will try to connect to port 9229.
        console.log('');
        console.log('Detected by debugger');
        loggedConnection = true;
      }
    }
  });
}

export async function destroy(api) {
  const config = api.getConfig();
  const options = api.getOptions();

  if (!options.force) {
    console.error('The destroy command completely removes the app from the server');
    console.error('If you are sure you want to continue, use the `--force` option');
    process.exit(1);
  } else {
    console.log('The app will be completely removed from the server.');
    console.log('Waiting 5 seconds in case you want to cancel by pressing ctr + c');
    await new Promise(resolve => setTimeout(resolve, 1000 * 5));
  }

  const list = nodemiral.taskList('Destroy App');
  const sessions = await getSessions(api);

  if (api.swarmEnabled()) {
    console.error('Destroying app when using swarm is not implemented');
    process.exit(1);
  }

  list.executeScript('Stop App', {
    script: api.resolvePath(__dirname, 'assets/meteor-stop.sh'),
    vars: {
      appName: config.app.name
    }
  });

  list.executeScript('Destroy App', {
    script: api.resolvePath(__dirname, 'assets/meteor-destroy.sh'),
    vars: {
      name: config.app.name
    }
  });

  return api.runTaskList(list, sessions, {
    series: true,
    verbose: api.verbose
  });
}

export async function status(api) {
  const config = api.getConfig();
  const {
    StatusDisplay
  } = api.statusHelpers;
  const overview = api.getOptions().overview;
  const servers = Object.keys(config.app.servers)
    .map(key => ({
      ...config.servers[key],
      name: key
    }));

  const results = await map(
    servers,
    server => getInformation(server, config.app.name, api),
    { concurrency: 2 }
  );
  const urlResults = await map(
    servers,
    server => checkUrls(server, config.app, api),
    { concurrency: 2 }
  );

  const display = new StatusDisplay(`Meteor Status - ${config.app.name}`);

  results.forEach((result, index) => {
    const urlResult = urlResults[index] || {};
    const section = display.addLine(`- ${result.host}: ${withColor(result.statusColor, result.status)}`, result.statusColor);

    section.addLine(`Created at ${result.created}`);
    section.addLine(`Restarted ${result.restartCount} times`, result.restartColor);

    if (result.env) {
      const envSection = section.addLine('ENV:');
      result.env.forEach(envVar => {
        envSection.addLine(`- ${envVar}`);
      });
    }

    createPortInfoLines(result.exposedPorts, result.publishedPorts, section);
    displayAvailability(result, urlResult, section);
  });

  display.show(overview);
}