@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
JavaScript
'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}