UNPKG

@applitools/execution-grid-tunnel

Version:

Allows user to run tests with exection-grid and navigate to private hosts and ips

429 lines (375 loc) 12.9 kB
'use strict' const os = require('os') const path = require('path') const crypto = require('crypto') const {promises: fs, existsSync, statSync} = require('fs') const nodeCleanup = require('node-cleanup') const {version} = require('../package.json') const {connectivityTest: connectivityTestFn} = require('../src/utils/connectivity-test') const { getExpectedBinHash, getBinHash, getSupportedPlatformAndArchList, } = require('@applitools/eg-frpc') const {getCacheDirectoryPath, FrpcDownloadError} = require('../src/utils') const { createDirectoryIfDoesntExist, createLogger, deleteOldFiles, extraceDataFromRequest, findFreePort, initSocks5ProxyServer, } = require('../src/utils') const {downloadFrpc, getFrpcDownloadLink, getManualFrpcInstallingErrorMessage} = require('./frpc') const createApp = require('../src/execution-grid-tunnel') let heartbeatIntervalId, deleteOldFilesInterval, cleanupFunction const HOUR_IN_MILLISECONDS = 60 * 60 * 1000 const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS const FILES_ROOT_DIRECTORY = path.resolve(os.tmpdir(), `eg-applitools`) const serviceLoggerDirectory = path.resolve(FILES_ROOT_DIRECTORY, 'execution-grid-tunnel-logs') createDirectoryIfDoesntExist(FILES_ROOT_DIRECTORY) let serviceLogger const PLATFORM = os.platform() const ARCH = os.arch() const CACHE_DIRECTORY_PATH = process.env.APPLITOOLS_EG_FRPC_CACHE_DIRECTORY || path.join(getCacheDirectoryPath(os.platform()), 'eg-applitools') let isPrepareEnvironmentRunning = false async function prepareEnvironment(args) { if (isPrepareEnvironmentRunning) { if (args['logger']) args['logger'].info({action: 'prepare-environment-called-while-already-running'}) if (serviceLogger) serviceLogger.info({action: 'prepare-environment-called-while-already-running'}) throw Error('PREPARE_ENV_ALREADY_RUNNING') } isPrepareEnvironmentRunning = true return prepareEnvironmentDo(args).finally(() => { isPrepareEnvironmentRunning = false }) } async function prepareEnvironmentDo({ shouldInstallFrpc = true, customTestCacheDirectoryPath, useTcpTunnel, egTunnelManagerUrl = process.env.APPLITOOLS_EG_TUNNEL_MANAGER_URL || 'https://exec-wus.applitools.com', logger, mode = process.env.APPLITOOLS_EG_TUNNEL_MODE_ENV, // Set it to 'development' only in tests } = {}) { if (logger) { if (!logger.info || !logger.warn || !logger.error) { throw new Error( 'Logger should support the next functions: logger.info, logger.warn, logger.log, logger.error', ) } if (serviceLogger && serviceLogger !== logger) { serviceLogger.info({action: 'replace-logger-on-prepare-environment'}) } serviceLogger = logger } else { serviceLogger = createLogger({ mode, dirname: serviceLoggerDirectory, level: process.env.APPLITOOLS_EG_TUNNEL_LOG_LEVEL || 'error', }) } const platform = PLATFORM, arch = ARCH const cacheDirectoryPath = customTestCacheDirectoryPath || CACHE_DIRECTORY_PATH const fileSuffix = PLATFORM === 'win32' ? '.exe' : '' const frpcPath = path.resolve(cacheDirectoryPath, `frpc${fileSuffix}`) serviceLogger.info({ action: 'start-prepare-environment', success: true, platform, arch, cacheDirectoryPath, useTcpTunnel, shouldInstallFrpc, }) await createDirectoryIfDoesntExist(CACHE_DIRECTORY_PATH) if (useTcpTunnel) { return {frpcPath: null} } const isPlatformAndArchSupported = getSupportedPlatformAndArchList().some( (item) => item.platform === platform && item.arch === arch, ) if (!isPlatformAndArchSupported) { serviceLogger.info({ action: 'unsupported-environment', success: false, platform, arch, }) throw `execution-grid-tunnel doesn't support ${PLATFORM} with ${ARCH} architecture yet` } serviceLogger.info({ action: 'start-frpc-verification', }) const expectedHash = getExpectedBinHash({platform, arch}) let isFrpcExists = existsSync(frpcPath) const binHash = isFrpcExists ? await getBinHash(frpcPath) : 'none' let isExpectedFrpc = isFrpcExists && binHash === expectedHash if (!isExpectedFrpc && !shouldInstallFrpc) { const url = getFrpcDownloadLink({platform, arch, egTunnelManagerUrl}) const message = getManualFrpcInstallingErrorMessage({ platform, arch, cacheDirectoryPath, frpcPath, shouldUpdate: isFrpcExists, egTunnelManagerUrl, }) serviceLogger.info({ action: 'verify-frpc-failed', reason: `user specified that frpc shouldn't be installed and the existing frpc is not the expected one`, isFrpcExists: isExpectedFrpc, isExpectedFrpc, expectedHash, actualHash: binHash, clientMessage: message, url: url || 'none', }) throw new FrpcDownloadError(url, CACHE_DIRECTORY_PATH, frpcPath, message) } if (!isExpectedFrpc) { await downloadFrpc({ platform, arch, cacheDirectoryPath, frpcPath, shouldUpdate: isFrpcExists, egTunnelManagerUrl, logger: serviceLogger, }) serviceLogger.info({ action: 'finish-frpc-verification', didDownloadFrpc: true, oldHash: binHash, newHash: expectedHash, }) } else { serviceLogger.info({ action: 'finish-frpc-verification', didDownloadFrpc: false, binHash, }) } return {frpcPath} } async function startEgTunnelService({ port = process.env.APPLITOOLS_EG_TUNNEL_PORT, host = process.env.APPLITOOLS_EG_TUNNEL_HOST || 'localhost', egTunnelManagerUrl = process.env.APPLITOOLS_EG_TUNNEL_DEBUG_MANAGER_URL, heartbeatInterval = process.env.APPLITOOLS_EG_TUNNEL_HEARTBEAT_INRERVAL || 3000, portRange = { min: parseInt(process.env.APPLITOOLS_EG_TUNNEL_MIN_PORT_RANGE) || 40000, max: parseInt(process.env.APPLITOOLS_EG_TUNNEL_MAX_PORT_RANGE) || 50000, }, tunnelConfigFileDirectory = process.env.APPLITOOLS_EG_TUNNEL_CONFIG_FILE_DIRECTORY || path.join(FILES_ROOT_DIRECTORY, 'tunnels/configs'), tunnelLogFileDirectory = process.env.APPLITOOLS_EG_TUNNEL_LOG_FILES_DIRECTORY || path.join(FILES_ROOT_DIRECTORY, 'tunnels/logs'), tcpTunnelOptions = { protocol: process.env.APPLITOOLS_EG_TUNNEL_LB_PROTOCOL || 'tls', host: process.env.APPLITOOLS_EG_TUNNEL_DEBUG_LB_HOST, port: process.env.APPLITOOLS_EG_TUNNEL_DEBUG_LB_PORT || 443, preAllocation: Math.max( 1, Number(process.env.APPLITOOLS_EG_TUNNEL_PRE_ALLOCATION_CONNECTIONS || 15), ), maxConnections: Math.max(1, Number(process.env.APPLITOOLS_EG_TUNNEL_MAX_CONNECTIONS || 500)), }, configFileMaxAge = parseInt(process.env.APPLITOOLS_EG_TUNNEL_CONFIG_FILE_MAX_AGE) || DAY_IN_MILLISECONDS, tunnelLogFileMaxAge = parseInt(process.env.APPLITOOLS_EG_TUNNEL_LOG_FILE_MAX_AGE) || DAY_IN_MILLISECONDS, egTunnelServiceLogFileMaxAge = 7 * DAY_IN_MILLISECONDS, logger, sendTunnelHeartBeatAlways = false, // Set it to true only in tests. By default, you shouldn't send heartbeat if there is no tunnel shouldInstallFrpc = true, customTestCacheDirectoryPath, mode = process.env.APPLITOOLS_EG_TUNNEL_MODE_ENV, // Set it to 'development' only in tests region = process.env.APPLITOOLS_EG_TUNNEL_REGION || 'us-west', useTcpTunnel = true, } = {}) { if (useTcpTunnel) { shouldInstallFrpc = false } if (!egTunnelManagerUrl) { egTunnelManagerUrl = region === 'australia-southeast' ? 'https://exec-au.applitools.com' : region === 'singapore' ? 'https://exec-sgp.applitools.com' : 'https://exec-wus.applitools.com' } if (!tcpTunnelOptions.host) { tcpTunnelOptions.host = region === 'australia-southeast' ? 'exec-tunnel-au.applitools.com' : region === 'singapore' ? 'exec-tunnel-sgp.applitools.com' : 'exec-tunnel-wus.applitools.com' } if (logger) { if (!logger.info || !logger.warn || !logger.error) { throw new Error( 'Logger should support the next functions: logger.info, logger.warn, logger.log, logger.error', ) } if (serviceLogger && serviceLogger !== logger) { serviceLogger.info({action: 'replace-logger-on-start-eg-tunnel'}) } serviceLogger = logger } else { serviceLogger = createLogger({ mode, dirname: serviceLoggerDirectory, level: process.env.APPLITOOLS_EG_TUNNEL_LOG_LEVEL || 'error', }) } serviceLogger.info({ action: 'start-eg-tunnel-service', version, egTunnelManagerUrl, region, shouldInstallFrpc, useTcpTunnel, }) const {frpcPath} = await prepareEnvironment({ shouldInstallFrpc, customTestCacheDirectoryPath, useTcpTunnel, logger: serviceLogger, }) createDirectoryIfDoesntExist(serviceLoggerDirectory) createDirectoryIfDoesntExist(tunnelConfigFileDirectory) createDirectoryIfDoesntExist(tunnelLogFileDirectory) // init socks5 proxy server const socks5ProxyPort = await findFreePort(portRange.min, portRange.max) const socks5Proxy = await initSocks5ProxyServer({ port: socks5ProxyPort, logger: serviceLogger, }) const socks5ProxyServer = socks5Proxy.server tcpTunnelOptions.loggerOptions = { mode, dirname: tunnelLogFileDirectory, level: process.env.APPLITOOLS_EG_TUNNEL_LOG_LEVEL || 'info', } serviceLogger.info({ action: 'init-socks5-proxy-server', success: true, port: socks5ProxyServer.address().port, }) const app = createApp({ egTunnelManagerUrl, tunnelConfigFileDirectory, logFileDirectory: tunnelLogFileDirectory, runTunnelBinPath: frpcPath, socks5Proxies: [socks5ProxyServer], portRange, tcpTunnelOptions, logger: serviceLogger, }) // remove old config and log files const deleteAllOldFiles = () => { const items = [ { directoryPath: tunnelConfigFileDirectory, maximunFileAge: configFileMaxAge, }, { directoryPath: tunnelLogFileDirectory, maximunFileAge: tunnelLogFileMaxAge, }, { directoryPath: serviceLoggerDirectory, maximunFileAge: egTunnelServiceLogFileMaxAge, }, ] items.forEach(({directoryPath, maximunFileAge}) => { deleteOldFiles({directoryPath, maximunFileAge}).catch((error) => { serviceLogger.error({ action: 'delete-old-files', success: false, directoryPath, maximunFileAge, error, }) }) }) } deleteAllOldFiles() deleteOldFilesInterval = setInterval(deleteAllOldFiles, HOUR_IN_MILLISECONDS) heartbeatIntervalId = setInterval(() => { const tunnelIds = app.tunnelProcessManager.getActiveTunnels() if (!tunnelIds.length && !sendTunnelHeartBeatAlways) return extraceDataFromRequest(app.egTunnelManager.sendHeartBeat(tunnelIds)).catch( ({error, statusCode}) => { serviceLogger.error({ action: 'send-heartbeat', success: false, error, statusCode, }) }, ) }, heartbeatInterval) await app.ready() if (port === undefined) { port = 0 } // const address = await app.listen({port, host}) const address = await app.listen({port, host}) if (port === 0) { port = app.server.address().port } console.log( `execution-grid-tunnel service is available on http://localhost:${app.server.address().port}`, ) serviceLogger.info({ action: 'listen-app', address, success: true, address: `http://localhost:${port}`, egTunnelManagerUrl, tunnelLb: tcpTunnelOptions.host, region, }) cleanupFunction = async (_exitCode, _signal) => { clearInterval(heartbeatIntervalId) clearInterval(deleteOldFilesInterval) // Stop all tunnels in cleanup app.tunnelProcessManager.stopAll().catch(console.log) app.close() socks5Proxy.close() !logger && serviceLogger.close() } const connectivityTest = (timeout = 10000) => connectivityTestFn({ host: tcpTunnelOptions.host, port: tcpTunnelOptions.port, protocol: tcpTunnelOptions.protocol, timeout, }) return {cleanupFunction, port, connectivityTest} } // Run the service automatically only when file is called directly (`npm start` or `node run-execution-grid-tunnel-server`) // It allows to test the main function if (require.main === module) { startEgTunnelService().catch(async (err) => { try { heartbeatIntervalId && clearInterval(heartbeatIntervalId) deleteOldFilesInterval && clearInterval(deleteOldFilesInterval) console.error(`Webserver crashed: ${err.stack || err.toString()}`) serviceLogger.error(`Webserver crashed: ${err.stack || err.toString()}`) } finally { process.exit(1) } }) nodeCleanup(cleanupFunction) } module.exports = {startEgTunnelService, prepareEnvironment}