const { detailedDiff } = require('deep-object-diff'); const Delay = require('delay'); const forEach = require('apr-for-each/series'); const map = require('apr-map/series'); const paramCase = require('param-case'); const camelCase = require('camel-case'); const buildArray = require('build-array'); const forceArray = require('force-array'); const lfind = require('lodash.find'); const findIndex = require('lodash.findindex'); const uniq = require('lodash.uniq'); const yaml = require('js-yaml'); const hasha = require('hasha'); const moment = require('moment'); const IPC = require('crocket'); const Boom = require('boom'); const { NODE_ENV, SOCK_PORT } = process.env; const IS_TEST = (NODE_ENV || '').toLowerCase() === 'test'; const emit = (() => { if (!IS_TEST) { return () => null; } let index = 0; const sock = new IPC(); sock.listen({ port: Number(SOCK_PORT) }, err => { if (err) throw err; // eslint-disable-next-line no-console console.log('sock bound to %d', SOCK_PORT); }); return (name, payload) => { sock.emit(name, { index: (index += 1), payload }); }; })(); const wpData = require('./wp-data.json'); const cpData = require('./cp-data.json'); const complexData = require('./complex-data.json'); const metricData = [ require('./metric-datasets-0.json'), require('./metric-datasets-1.json'), require('./metric-datasets-2.json') ]; const { datacenter, portal } = require('./data.json'); const deploymentGroups = [ wpData.deploymentGroup, cpData.deploymentGroup, complexData.deploymentGroup ]; const services = wpData.services .concat(cpData.services) .concat(complexData.services); const instances = wpData.instances .concat(cpData.instances) .concat(complexData.instances); const INTERPOLATE_REGEX = /\$([_a-z][_a-z0-9]*)/gi; const wait = (s, fn) => setTimeout(fn, s * 1000); // eslint-disable-next-line new-cap const delay = s => Delay(s * 1000); const find = (query = {}) => item => Object.keys(query).every(key => item[key] === query[key]); const cleanQuery = (q = {}) => JSON.parse(JSON.stringify(q)); let metricDataIndex = 0; const cleanDiff = (diff, meta) => { const obj = Object.assign(diff, meta); return Object.keys(obj).reduce((acc, key) => { if ( ['added', 'deleted', 'updated'].indexOf(key) >= 0 && !Object.keys(obj[key]).length ) { return acc; } acc[key] = obj[key]; return acc; }, {}); }; const diff = (before, after, metaProp) => { if (!IS_TEST) { return; } const detail = detailedDiff(before, after); const cleaned = cleanDiff(detail, { [metaProp]: before[metaProp] }); return ['added', 'deleted', 'updated'].reduce((diff, type) => { if (!diff[type]) { return diff; } return Object.assign(diff, { [type]: Object.keys(diff[type]).reduce( (change, name) => Object.assign(change, { [name]: { before: before[name], after: diff[type][name] } }), {} ) }); }, cleaned); }; /** *************************************************************************** */ const getMetrics = query => { const { names, start, end, instanceId } = query; const metrics = names.reduce((metrics, name) => { // pick one of the three metric data jsons, so there's variety const index = metricDataIndex % metricData.length; metricDataIndex++; const md = metricData[index].find(md => md.name === name); const m = md.metrics; const s = moment.utc(start); const e = moment.utc(end); // how many records do we need? const duration = e.diff(s); // duration for which we need data const records = Math.floor(duration / 15000); // new metric record every 15 secs const requiredMetrics = []; let i = 0; const time = moment(s); // start at a random point within the dataset for variety const randomIndex = Math.round(Math.random() * m.length); while (i < records) { const index = (randomIndex + i) % m.length; // loop if not enough data const requiredMetric = m[index]; requiredMetric.time = time .add(15, 'seconds') .utc() .format(); // we should have a new record every 15 secs requiredMetrics.push(requiredMetric); i++; } const requiredMetricData = { instance: instanceId, name, start: s.utc().format(), end: time.utc().format(), // this will be used by the frontend for the next fetch metrics: requiredMetrics }; metrics.push(requiredMetricData); return metrics; }, []); return Promise.resolve(metrics); }; const getInstanceMetrics = ({ id }) => query => getMetrics( Object.assign({}, query, { instanceId: id }) ); const updateInstance = async query => { await delay(0.5); const instanceIndex = findIndex(instances, ['id', query.id]); const original = cleanQuery(instances[instanceIndex]); instances[instanceIndex] = Object.assign( instances[instanceIndex], cleanQuery(query) ); emit('instance-updated', diff(original, instances[instanceIndex], 'name')); return instances[instanceIndex]; }; const getInstances = async query => { await delay(0.1); return instances.filter(find(cleanQuery(query))).map(instance => Object.assign({}, instance, { metrics: getInstanceMetrics(instance) }) ); }; const getInstance = async query => { const instance = (await getInstances(query)).shift(); if (!instance) { throw Boom.notFound(); } return instance; }; // Just creates an instance, but doesn't move it out of PROVISIONING // it doesn't append the isntance to the global list of instances const createInstance = async (service, i) => { await delay(0.5); const _instance = { name: camelCase(`${service.slug}_${i || 1}`), deploymentGroupId: service.deploymentGroupId, serviceId: service.id }; const instance = Object.assign({}, _instance, { id: hasha(JSON.stringify(_instance)), status: 'PROVISIONING' }); emit('instance-created', instance); return instance; }; // Takes a PROVISIONING instance and moves it to RUNNING // it doesn't append the isntance to the global list of instances const provisionInstance = async instance => { await delay(3); await updateInstance({ id: instance.id, status: 'READY' }); await delay(1); await updateInstance({ id: instance.id, status: 'RUNNING' }); emit('instance-provisioned', instance.id); return instance; }; const stopInstance = async id => { const instance = await getInstance({ id }); if (!instance) { return; } if (instance.status === 'STOPPED') { return instance; } await delay(1); await updateInstance({ id, status: 'STOPPING' }); await delay(0.5); await updateInstance({ id, status: 'STOPPED' }); emit('instance-stopped', id); return getInstance({ id }); }; const deleteInstance = async id => { const instance = await getInstance({ id }); if (!instance) { return; } if (instance.status === 'DELETED') { return instance; } await stopInstance(id); await delay(1); await updateInstance({ id, status: 'DELETED' }); emit('instance-deleted', id); return instance; }; const startInstance = async id => { const instance = await getInstance({ id }); if (!instance) { return; } if (instance.status === 'RUNNING') { return instance; } await updateInstance({ id, status: 'READY' }); await delay(1); await updateInstance({ id, status: 'RUNNING' }); emit('instance-started', id); return instance; }; const restartInstance = async id => { const instance = await getInstance({ id }); if (!instance) { return; } await stopInstance(id); await delay(1); await startInstance(id); emit('instance-restarted', id); return instance; }; const updateService = async query => { await delay(0.5); const serviceIndex = findIndex(services, ['id', query.id]); const original = cleanQuery(services[serviceIndex]); services[serviceIndex] = Object.assign(services[serviceIndex], query); emit('service-updated', diff(original, services[serviceIndex], 'slug')); return services[serviceIndex]; }; // Just creates a service, but doesn't move it out of PROVISIONING // it doesn't append the service to the global list of services const createService = async ({ deploymentGroupId, name }) => { await delay(0.5); const _service = { deploymentGroupId, slug: paramCase(name), name }; const service = Object.assign({}, _service, { id: hasha(JSON.stringify(_service)), status: 'PROVISIONING' }); emit('service-created', service); return service; }; // Takes a PROVISIONING service and moves it to ACTIVE // it doesn't append the service to the global list of services // it does append the created instances to the global list of instances const provisionService = async service => { const instance = await createInstance(service); instances.push(instance); await provisionInstance(instance); await delay(0.5); return updateService({ id: service.id, status: 'ACTIVE' }); }; const deleteService = async id => { const service = await getService({ id }); if (service.status !== 'DELETED') { await updateService({ id, status: 'DELETING' }); } const instances = await getInstances({ serviceId: id }); await forEach(instances.map(({ id }) => id), deleteInstance); await delay(0.5); if (service.status !== 'DELETED') { await updateService({ id, status: 'DELETED' }); emit('service-deleted', id); } return getService({ id }); }; const deleteServices = ({ ids }) => { wait(0.5, async () => { await forEach(ids, deleteService); emit('services-deleted', ids); }); return forEach(ids, id => getService({ id })); }; const getServiceInstances = async (query, { id }) => { await delay(0.1); const filter = Object.assign({}, query, { serviceId: id }); return (await getInstances(filter)).filter( ({ status }) => ['DELETED', 'EXITED'].indexOf(status) < 0 ); }; const getBranchInstances = async (query, instanceIds) => map(instanceIds, id => getInstance({ id })); const getServiceBranches = async (query, { branches }) => { await delay(0.1); return forceArray(branches) .filter(find(cleanQuery(query))) .map(branch => Object.assign({}, branch, { id: hasha(JSON.stringify(branch)), instances: query => getBranchInstances(query, branch.instances) }) ); }; const getServices = async query => { await delay(0.1); const _services = services.filter(find(cleanQuery(query))).map(service => Object.assign({}, service, { instances: query => getServiceInstances(query, service), branches: query => getServiceBranches(query, service) }) ); if ( (query.id || query.name || query.slug) && (!_services || !_services.length) ) { throw Boom.notFound(); } return _services; }; const getService = async query => { const service = (await getServices(query)).shift(); if (!service) { throw Boom.notFound(); } return service; }; const restartService = async id => { await updateService({ id, status: 'RESTARTING' }); const instances = await getInstances({ serviceId: id }); await forEach(instances.map(({ id }) => id), restartInstance); await updateService({ id, status: 'ACTIVE' }); emit('service-restarted', id); return getService({ id }); }; const restartServices = async ({ ids }) => { wait(1, async () => { await forEach(ids, restartService); emit('services-restarted', ids); }); return forEach(ids, id => getService({ id })); }; const stopService = async id => { const service = await getService({ id }); if (service.status !== 'STOPPED') { await updateService({ id, status: 'STOPPING' }); } const instances = await getInstances({ serviceId: id }); await forEach(instances.map(({ id }) => id), stopInstance); if (service.status !== 'STOPPED') { await updateService({ id, status: 'STOPPED' }); emit('service-stopped', id); } return getService({ id }); }; const stopServices = async ({ ids }) => { wait(1, async () => { await forEach(ids, stopService); emit('services-stopped', ids); }); return forEach(ids, id => getService({ id })); }; const startService = async id => { const instances = await getInstances({ serviceId: id }); await forEach(instances.map(({ id }) => id), startInstance); const service = await getService({ id }); if (service.status !== 'ACTIVE') { await updateService({ id, status: 'ACTIVE' }); } return getService({ id }); }; const startServices = async ({ ids }) => { wait(1, async () => { await forEach(ids, startService); emit('services-started', ids); }); return forEach(ids, id => getService({ id })); }; const scale = async ({ serviceId, replicas }) => { const service = lfind(services, ['id', serviceId]); const currentScale = instances.filter( find({ serviceId }) ).length; const dg = await getDeploymentGroup({ id: service.deploymentGroupId }); if (currentScale === replicas) { return dg.version; } const version = { id: hasha(JSON.stringify({ currentScale, serviceId, replicas })) }; await updateDeploymentGroup({ id: service.deploymentGroupId, history: forceArray(dg.history).concat(version), version }); await updateService({ id: serviceId, status: 'SCALING' }); const up = async n => forEach(buildArray(n), async (_, i) => { const instance = await createInstance(service, currentScale + i); instances.push(instance); await delay(3); await updateInstance({ id: instance.id, status: 'READY' }); await delay(1); await updateInstance({ id: instance.id, status: 'RUNNING' }); }); const down = async n => { const instances = (await getInstances({ serviceId })) .map(({ id }) => id) .slice(0, n); await forEach(instances, deleteInstance); }; wait(1, async () => { const diff = replicas - currentScale; const fn = diff >= 0 ? up : down; await fn(Math.abs(diff)); await delay(1); await updateService({ id: serviceId, status: 'ACTIVE' }); emit('service-scaled', serviceId); }); return version; }; const provisionManifest = async query => { const { deploymentGroupId, raw } = query; const version = { id: hasha(JSON.stringify(query)), type: query.type, format: query.format }; const dg = await getDeploymentGroup({ id: deploymentGroupId }); await updateDeploymentGroup({ id: deploymentGroupId, status: 'PROVISIONING' }); wait(3, async () => { const _services = await map(Object.keys(yaml.safeLoad(raw)), async name => { const service = await createService({ deploymentGroupId, name }); services.push(service); return service; }); await updateDeploymentGroup({ id: deploymentGroupId, status: 'ACTIVE' }); await forEach(_services, provisionService); emit('manifest-provisioned', version.id); }); await updateDeploymentGroup({ id: deploymentGroupId, history: forceArray(dg.history).concat(version), version }); return version; }; const createDeploymentGroup = async ({ name }) => { await delay(1); const _dg = { slug: paramCase(name), name }; const dg = Object.assign({}, _dg, { id: hasha(JSON.stringify(_dg)), status: 'ACTIVE' }); deploymentGroups.push(dg); emit('dg-created', dg); return dg; }; const getDeploymentGroups = async query => { await delay(0.1); const addNestedResolvers = dg => Object.assign({}, dg, { services: () => getServices({ deploymentGroupId: dg.id }) }); const dgs = deploymentGroups .filter(find(cleanQuery(query))) .map(addNestedResolvers); if ((query.ids || query.name || query.slug) && (!dgs || !dgs.length)) { throw Boom.notFound(); } return dgs; }; const getDeploymentGroup = async query => (await getDeploymentGroups(query)).shift(); const updateDeploymentGroup = async query => { await delay(0.5); const dgIndex = findIndex(deploymentGroups, ['id', query.id]); const original = cleanQuery(deploymentGroups[dgIndex]); deploymentGroups[dgIndex] = Object.assign(deploymentGroups[dgIndex], query); emit('dg-updated', diff(original, deploymentGroups[dgIndex], 'slug')); return deploymentGroups[dgIndex]; }; const deleteDeploymentGroup = async ({ id }) => { const dg = await getDeploymentGroup({ id }); await updateDeploymentGroup({ id, status: 'DELETING' }); wait(1, async () => { const services = await dg.services(); await forEach(services.map(({ id }) => id), deleteService); await updateDeploymentGroup({ id, status: 'DELETED' }); emit('dg-deleted', id); }); return getDeploymentGroup({ id }); }; const getPortal = async () => { await delay(0.1); return Object.assign({}, portal, { datacenter, deploymentGroups: getDeploymentGroups }); }; const parseEnvVars = (str = '') => str .split(/\n/g) .filter(line => line.match(/\=/g)) .map(line => line.split(/\=/)) .reduce( (acc, [name, value]) => Object.assign(acc, { [name.trim()]: value.trim() }), {} ); const findEnvInterpolation = (str = '') => uniq(str.match(INTERPOLATE_REGEX).map(name => name.replace(/^\$/, ''))); const config = ({ environment = '', files = [], raw = '', _plain = false }) => { const interpolatableNames = findEnvInterpolation(raw); const interpolatableEnv = parseEnvVars(environment); const interpolatedRaw = interpolatableNames.reduce( (str = '', name) => str.replace(new RegExp(`\\$${name}`), interpolatableEnv[name]), raw ); const manifest = yaml.safeLoad(interpolatedRaw); const services = manifest.services || manifest; const config = Object.keys(services) .map(name => Object.assign(services[name], { name }) ) // eslint-disable-next-line camelcase .map(({ name, image, env_file, environment }) => ({ name, slug: paramCase(name), instances: [], config: { image, environment: forceArray(env_file).reduce( (env, file) => Object.assign( env, parseEnvVars(lfind(files, ['name', file]).value) ), forceArray(environment) .map(parseEnvVars) .reduce( (genv, variable) => Object.assign(genv, variable), interpolatableEnv ) ) } })) .map(service => Object.assign(service, { id: hasha(JSON.stringify(service)), config: Object.assign(service.config, { id: hasha(JSON.stringify(service.config)), environment: Object.keys(service.config.environment).map(name => ({ name, id: hasha(JSON.stringify(service.config.environment[name])), value: service.config.environment[name] })) }) }) ); return _plain ? config : Promise.resolve(config); }; module.exports = { portal: getPortal, deploymentGroups: getDeploymentGroups, deploymentGroup: getDeploymentGroup, services: getServices, service: getService, instances: getInstances, instance: getInstance, createDeploymentGroup, config, provisionManifest, deleteDeploymentGroup, deleteServices, scale, restartServices, stopServices, startServices, getMetrics };