UNPKG

@hashgraph/hedera-local

Version:

Developer tooling for running Local Hedera Network (Consensus + Mirror Nodes).

440 lines (392 loc) 17.1 kB
// SPDX-License-Identifier: Apache-2.0 import Dockerode from 'dockerode'; import shell from 'shelljs'; import semver from'semver'; import fs from 'fs'; import { IS_WINDOWS, NECESSARY_PORTS, UNKNOWN_VERSION, OPTIONAL_PORTS, MIN_CPUS, MIN_MEMORY_MULTI_MODE, MIN_MEMORY_SINGLE_MODE, RECOMMENDED_CPUS, RECOMMENDED_MEMORY_SINGLE_MODE, CHECK_SUCCESS, CHECK_FAIL, LOADING, SHARED_PATHS_ERROR, DOCKER_PULLING_IMAGES_MESSAGE, MOUNT_ERROR, } from '../constants'; import { IService } from './IService'; import { LoggerService } from './LoggerService'; import { ServiceLocator } from './ServiceLocator'; import detectPort from 'detect-port'; import * as dotenv from 'dotenv'; import { CLIOptions } from '../types/CLIOptions'; import path from 'path'; import { SafeDockerNetworkRemover } from '../utils/SafeDockerNetworkRemover'; import yaml from 'js-yaml'; dotenv.config(); /** * DockerService is a service class that handles Docker-related operations. * It implements the IService interface. * It uses the 'dockerode' library to interact with Docker, 'shelljs' to execute shell commands, and 'semver' to compare semantic versioning numbers. */ export class DockerService implements IService{ /** * The logger service used for logging. * @private */ private logger: LoggerService; /** * The name of the service. * @private */ private serviceName: string; /** * The Docker socket path. * @private */ private dockerSocket: string; /** * Constructs a new instance of the DockerService. * Initializes the logger and Docker socket path. */ constructor() { this.serviceName = DockerService.name; this.logger = ServiceLocator.Current.get<LoggerService>(LoggerService.name); this.logger.trace(`${CHECK_SUCCESS} Docker Service Initialized!`, this.serviceName); const defaultSocketPath = IS_WINDOWS ? '//./pipe/docker_engine' : '/var/run/docker.sock'; this.dockerSocket = process.env.DOCKER_SOCKET || defaultSocketPath; } /** * Returns the Docker socket path. * * @public * @returns {string} - The Docker socket path. */ public getDockerSocket(): string { return this.dockerSocket; } /** * Returns the null output path depending on the operating system. * * @public * @returns {string} - The null output path. */ public getNullOutput(): "null" | "/dev/null" { if (IS_WINDOWS) return 'null'; return '/dev/null'; } /** * Checks if Docker is running. * * @public * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether Docker is running. */ public async checkDocker (): Promise<boolean> { let isRunning = false; const docker = new Dockerode({ socketPath: this.dockerSocket }); await docker .info() .then(() => { this.logger.trace(`${CHECK_SUCCESS} Docker is running.`, this.serviceName); isRunning = true; }) .catch(() => { this.logger.error(`${CHECK_FAIL} Docker is not running.`, this.serviceName); isRunning = false; }); return isRunning; } /** * Checks if the provided ports are in use. * If necessary ports are in use, it terminates the process. * * @param {number[]} portsToCheck - The ports to check. * @public * @returns {Promise<void>} - A promise that resolves when the ports are checked. * @throws If an error occurs during the port check. */ public async isPortInUse (portsToCheck: number[]): Promise<void> { const promises: Promise<boolean>[] = portsToCheck.map((port:number) => detectPort(port) .then((available: number) => available !== port) .catch((error: Error) => { // Handle the error throw error; })); const resolvedPromises: boolean[] = await Promise.all(promises); resolvedPromises.forEach((result, index) => { const port = portsToCheck[index]; if (result && OPTIONAL_PORTS.includes(port)) { this.logger.warn(`Port ${port} is in use.`, this.serviceName); } else if (result && NECESSARY_PORTS.includes(port)) { this.logger.error(`${CHECK_FAIL} Port ${port} is in use.`, this.serviceName); } }); const resolvedPromisesNecessaryPortsOnly = resolvedPromises.slice(0, NECESSARY_PORTS.length); if(!(resolvedPromisesNecessaryPortsOnly.every(value => value === false))) { this.logger.error(`${CHECK_FAIL} Node cannot start properly because necessary ports are in use!`, this.serviceName); process.exit(1); } } /** * Checks if the installed Docker Compose version is correct (greater than 2.12.2). * * @public * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether the Docker Compose version is correct. */ public async isCorrectDockerComposeVersion (): Promise<boolean> { this.logger.info(`${LOADING} Checking docker compose version...`, this.serviceName); // We are executing both commands because in Linux we may have docker-compose v2, so we need to check both const resultFirstCommand = await shell.exec( 'docker compose version --short', { silent: true } ); const resultSecondCommand = await shell.exec( 'docker-compose version --short', { silent: true } ); // Exit code is 127 when no docker installation is found if (resultFirstCommand.code === 127 && resultSecondCommand.code === 127) { this.logger.error(`Please install docker compose V2.`, this.serviceName); } else if ( resultFirstCommand.code === 127 && resultSecondCommand.code === 0 ) { this.logger.error( `Looks like you have docker-compose V1, but you need docker compose V2!`, this.serviceName ); } else { const version = resultFirstCommand.stdout ? resultFirstCommand.stdout : resultSecondCommand.stdout; if (semver.gt(version, '2.12.2')) { // Docker version is OK return true; } this.logger.error( 'You are using docker compose version prior to 2.12.2, please upgrade', this.serviceName ); } return false; } public async checkDockerResources(isMultiNodeMode: boolean) { this.logger.info(`${LOADING} Checking docker resources...`, this.serviceName); const ncpu = await shell.exec( "docker info --format='{{.NCPU}}'", { silent: true } ); const memTotal = await shell.exec( "docker info --format='{{.MemTotal}}'", { silent: true } ); const dockerMemory = Math.round(parseInt(memTotal) / Math.pow(1024, 3)); const dockerCPUs = parseInt(ncpu); return this.checkMemoryResources(dockerMemory, isMultiNodeMode) && this.checkCPUResources(dockerCPUs); } public checkDockerImages() { const dockerComposeYml = yaml.load(shell.exec("docker compose config", { silent: true }).stdout) as any; const dockerComposeImages = Object.values(dockerComposeYml.services).map((s: any) => { if (s.image) { const parsed = s.image.split(":"); return `${parsed[0]}:${parsed[1] ?? "latest"}`; } }); const dockerComposeImagesUnique = [...new Set(dockerComposeImages.sort())]; const dockerImagesString = shell.exec("docker images", { silent: true }).stdout.split(/\r?\n/).slice(1, -1); const dockerImages = dockerImagesString.map(line => { const parsed = line.replace(/\s\s+/g, " ").split(" "); return `${parsed[0]}:${parsed[1]}`; }); const dockerImagesUnique = [...new Set(dockerImages.sort())]; if (!dockerImages.length || dockerComposeImagesUnique.toString() != dockerImagesUnique.toString()) { this.logger.info(DOCKER_PULLING_IMAGES_MESSAGE, this.serviceName); } } private checkMemoryResources(dockerMemory: number, isMultiNodeMode: boolean) { if ((dockerMemory >= MIN_MEMORY_SINGLE_MODE && dockerMemory < RECOMMENDED_MEMORY_SINGLE_MODE && !isMultiNodeMode) || (dockerMemory < MIN_MEMORY_SINGLE_MODE && !isMultiNodeMode) || (dockerMemory < MIN_MEMORY_MULTI_MODE && isMultiNodeMode)) { if (dockerMemory < MIN_MEMORY_SINGLE_MODE) { this.handleMemoryError(dockerMemory, isMultiNodeMode); } else { this.logger.warn(`Your docker memory resources are ${dockerMemory.toFixed(2)}GB, which may cause unstable behaviour. Set to at least ${isMultiNodeMode ? MIN_MEMORY_MULTI_MODE : RECOMMENDED_MEMORY_SINGLE_MODE}GB`, this.serviceName); return true; } return false; } return true; } private checkCPUResources(dockerCPUs: number) { if(dockerCPUs >= MIN_CPUS && dockerCPUs < RECOMMENDED_CPUS && !process.env.CI) { this.logger.warn(`Your docker CPU resources are set to ${dockerCPUs}, which may cause unstable behaviour. Set to at least ${RECOMMENDED_CPUS} CPUs`, this.serviceName); return true; } else if (dockerCPUs < MIN_CPUS && !process.env.CI) { this.logger.error(`Your docker CPU resources are set to ${dockerCPUs}. This is not enough, set to at least ${RECOMMENDED_CPUS} CPUs`, this.serviceName); return false; } return true; } private handleMemoryError(dockerMemory: number, isMultiNodeMode: boolean) { const recommendedMemory = isMultiNodeMode ? MIN_MEMORY_MULTI_MODE : MIN_MEMORY_SINGLE_MODE; this.logger.error(`Your docker memory resources are set to ${dockerMemory.toFixed(2)}GB. This is not enough, set to at least ${recommendedMemory}GB`, this.serviceName); } private logShellOutput(shellExec: any) { [shellExec.stdout, shellExec.stderr].forEach( (output: string) => { output.split("\n").map((line: string) => { if (line.indexOf(SHARED_PATHS_ERROR) > -1 || line.indexOf(MOUNT_ERROR) > -1) { this.logger.error(`Hedera local node start up TERMINATED due to docker's misconfiguration`); this.logger.error(SHARED_PATHS_ERROR); this.logger.error(`See https://docs.docker.com/desktop/settings/mac/#file-sharing for more info.`); this.logger.error(`-- Make sure you have '/Users/<your_user>/Library/' under File Sharing Docker's Setting.`); this.logger.error(`-- If you're using hedera-local as npm package - running 'npm root -g' should output the path you have to add under File Sharing Docker's Setting.`); this.logger.error(`-- If you're using hedera-local as cloned repo - running 'pwd' in the project's root should output the path you have to add under File Sharing Docker's Setting.`); process.exit(); } if (line === "") return; this.logger.debug(line, this.serviceName); }); }); } private async executeExternal(command: string, options = {}): Promise<shell.ShellString> { this.logger.trace(`🚀 Executing command: ${command}`, this.serviceName); const shellExec = shell.exec(command, options); this.logShellOutput(shellExec); return shellExec; } /** * Returns a Docker container object for the given container label. * * @param {string} containerLabel - The label of the container. * @returns {Promise<Dockerode.Container>} - A promise that resolves to a Docker container object. * @public */ public async getContainer(containerLabel: string): Promise<Dockerode.Container> { const containerId = await this.getContainerId(containerLabel) as string; const docker = new Dockerode({ socketPath: this.getDockerSocket(), }); return docker.getContainer(containerId); } /** * Returns the ID of the Docker container with the given name. * * @param {string} name - The name of the container. * @returns {Promise<string>} - A promise that resolves to the ID of the Docker container. * @public */ public async getContainerId (name: string): Promise<string> { const docker = new Dockerode({ socketPath: this.dockerSocket }); const opts = { limit: 1, filters: { name: [`${name}`] } }; return new Promise((resolve, reject) => { docker.listContainers(opts, (err, containers) => { if (err) { reject(err); } else { resolve(containers![0].Id); } }); }); } /** * Returns the version of the Docker container with the given name. * * @param {string} name - The name of the container. * @returns {Promise<string>} - A promise that resolves to the version of the Docker container. * @public * @async */ public async getContainerVersion (name: string): Promise<string> { const docker = new Dockerode({ socketPath: this.dockerSocket }); const opts = { limit: 1, filters: { name: [`${name}`] } }; return new Promise((resolve, reject) => { docker.listContainers(opts, (err, containers) => { if (err) { reject(err); } else { try { resolve(containers![0].Image.split(':')[1]); } catch (e) { resolve(UNKNOWN_VERSION); } } }); }); } /** * Executes the docker compose up command. * * @private * @returns {Promise<shell.ShellString>} A promise that resolves with the output of the command. */ public async dockerComposeUp(cliOptions: CLIOptions): Promise<shell.ShellString> { // TODO: Add multi node option const composeFiles = ['docker-compose.yml']; const { fullMode } = cliOptions; const { userCompose } = cliOptions; const { userComposeDir } = cliOptions; const { multiNode } = cliOptions; const { blockNode } = cliOptions; if (!fullMode) { composeFiles.push('docker-compose.evm.yml'); } if (!blockNode) { composeFiles.push('docker-compose.block-node.yml'); } if (multiNode) { composeFiles.push('docker-compose.multinode.yml'); if (blockNode) { composeFiles.push('docker-compose.multinode.blocknode.yml'); } } if (!fullMode && multiNode) { composeFiles.push('docker-compose.multinode.evm.yml'); } if (userCompose) { composeFiles.push(...this.getUserComposeFiles(userComposeDir)); } return this.executeExternal(`docker compose -f ${composeFiles.join(' -f ')} up -d`, {silent: true}); } /** * Retrieves an array of user compose files from the specified directory. * * @private * @param {string} userComposeDir - The directory path where the user compose files are located. Defaults to './overrides/'. * @returns {Array<string>} An array of user compose file paths. */ private getUserComposeFiles(userComposeDir: string = './overrides/'): Array<string> { let dirPath = path.normalize(userComposeDir); if (!dirPath.endsWith(path.sep)) { dirPath += path.sep; } if (fs.existsSync(dirPath)) { const files = fs .readdirSync(dirPath) .filter((file) => path.extname(file).toLowerCase() === '.yml') .sort() .map((file) => dirPath.concat(file)); return files; } return []; } /** * Tries to recover the state by performing Docker recovery steps. * Stops the docker containers, cleans volumes and temp files, and tries to startup again. * @returns {Promise<void>} A promise that resolves when the recovery steps have completed. */ public async tryDockerRecovery(stateName: string): Promise<void> { const nullOutput = this.getNullOutput(); this.logger.trace('Stopping the docker containers...', stateName); this.executeExternal(`docker compose kill --remove-orphans`, {silent: true}); this.executeExternal(`docker compose down -v --remove-orphans`, {silent: true}); this.logger.trace('Cleaning the volumes and temp files...', stateName); shell.exec(`rm -rf network-logs/* >${nullOutput} 2>&1`); SafeDockerNetworkRemover.removeAll(); this.logger.info(`${LOADING} Trying to startup again...`, stateName); } }