UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

298 lines (266 loc) 9.83 kB
// SPDX-License-Identifier: Apache-2.0 import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; import {HelmExecutionException} from '../helm-execution-exception.js'; import {HelmParserException} from '../helm-parser-exception.js'; import {type Duration} from '../../../core/time/duration.js'; import {type SoloLogger} from '../../../core/logging/solo-logger.js'; import {SensitiveDataRedactor} from '../../../core/util/sensitive-data-redactor.js'; /** * Represents the execution of a helm command and is responsible for parsing the response. */ export class HelmExecution { /** * The logger for this class which should be used for all logging. */ private static readonly MSG_TIMEOUT_ERROR: string = 'Timed out waiting for the process to complete'; /** * The message for a timeout error. */ private static readonly MSG_DESERIALIZATION_ERROR: string = 'Failed to deserialize the output into the specified class: %s'; /** * The message for a deserialization error. */ private static readonly MSG_LIST_DESERIALIZATION_ERROR: string = 'Failed to deserialize the output into a list of the specified class: %s'; private readonly process: ChildProcessWithoutNullStreams; private readonly commandLine: string; private readonly logger?: SoloLogger; private output: string[] = []; private errOutput: string[] = []; private exitCodeValue: number | null = null; /** * Redacts sensitive arguments from a command array. * Delegates to the shared {@link SensitiveDataRedactor} utility. * @param command The command array to redact * @returns A new redacted command array */ public static redactCommand(command: string[]): string[] { return SensitiveDataRedactor.redactArguments(command, { flagsToRedactNextArgument: ['--password'], setStyleFlags: ['--set', '--set-string', '--set-file'], }); } /** * Creates a new HelmExecution instance. * @param command The command array to execute * @param environmentVariables The environment variables to set * @param logger Optional logger for command output */ public constructor(command: string[], environmentVariables: Record<string, string>, logger?: SoloLogger) { this.logger = logger; const redactedCommand: string[] = HelmExecution.redactCommand(command); this.commandLine = redactedCommand.join(' '); if (this.logger) { this.logger.info(`Executing helm command: ${this.commandLine}`); } this.process = spawn(command.join(' '), { shell: true, env: {...process.env, ...environmentVariables}, }); } /** * Waits for the process to complete. * @returns A promise that resolves when the process completes */ public async waitFor(): Promise<void> { return new Promise((resolve, reject): void => { // const output: string[] = []; this.process.stdout.on('data', (d): void => { const items: string[] = d.toString().split(/\r?\n/); for (const item of items) { if (item) { this.output.push(item); } } }); this.process.stderr.on('data', (d): void => { const items: string[] = d.toString().split(/\r?\n/); for (const item of items) { if (item) { this.errOutput.push(item.trim()); } } }); this.process.on('close', (code): void => { this.exitCodeValue = code; if (code === 0) { resolve(); } else { reject( new HelmExecutionException( code || 1, `Helm command failed with exit code ${code}. Command: '${this.commandLine}'. Error: ${this.standardError()}`, this.standardOutput(), this.standardError(), ), ); } }); }); } /** * Waits for the process to complete with a timeout. * @param timeout The maximum time to wait, or null to wait indefinitely * @returns A promise that resolves with true if the process completed, or false if it timed out */ private async waitForTimeout(timeout: Duration): Promise<boolean> { const timeoutPromise: Promise<boolean> = new Promise<boolean>((resolve): void => { setTimeout((): void => resolve(false), timeout.toMillis()); }); const successPromise: Promise<boolean> = new Promise<boolean>((resolve): void => { this.process.on('close', (code): void => { resolve(code === 0); }); }); return Promise.race([successPromise, timeoutPromise]); } /** * Gets the exit code of the process. * @returns The exit code or null if the process hasn't completed */ private exitCode(): number | null { return this.exitCodeValue; } /** * Gets the standard output of the process. * @returns concatenated standard output as a string */ private standardOutput(): string { return this.output.join(''); } /** * Gets the standard error of the process. * @returns concatenated standard error as a string */ private standardError(): string { return this.errOutput.join(''); } /** * Gets the response as a parsed object. * @param responseClass The class to parse the response into * @returns A promise that resolves with the parsed response */ public async responseAs<T>(responseClass: new (...arguments_: any[]) => T): Promise<T> { return this.responseAsTimeout(responseClass, null); } /** * Gets the response as a parsed object with a timeout. * @param responseClass The class to parse the response into * @param timeout The maximum time to wait, or null to wait indefinitely * @returns A promise that resolves with the parsed response or rejects on timeout */ public async responseAsTimeout<T>( responseClass: new (...arguments_: any[]) => T, timeout: Duration | null, ): Promise<T> { if (timeout === null) { await this.waitFor(); } else { const success: boolean = await this.waitForTimeout(timeout); if (!success) { throw new HelmParserException(HelmExecution.MSG_TIMEOUT_ERROR); } } const exitCode: number = this.exitCode(); if (exitCode !== 0) { const stdOut: string = this.standardOutput(); const stdError: string = this.standardError(); throw new HelmExecutionException( exitCode, `Helm command failed with exit code ${exitCode}. Command: '${this.commandLine}'. Error: ${stdError}`, stdOut, stdError, ); } if (responseClass === undefined) { return null; } const output: string = this.standardOutput(); try { const parsed: any = JSON.parse(output); const result: T = new responseClass(); Object.assign(result, parsed); return result; } catch { throw new HelmParserException(HelmExecution.MSG_DESERIALIZATION_ERROR.replace('%s', responseClass.name)); } } /** * Gets the response as a list of parsed objects. * @param responseClass The class to parse each item in the response into * @returns A promise that resolves with the parsed response list */ public async responseAsList<T>(responseClass: new (...arguments_: any[]) => T): Promise<T[]> { return this.responseAsListTimeout(responseClass, null); } /** * Gets the response as a list of parsed objects with a timeout. * @param responseClass The class to parse each item in the response into * @param timeout The maximum time to wait, or null to wait indefinitely * @returns A promise that resolves with the parsed response list or rejects on timeout */ public async responseAsListTimeout<T>( responseClass: new (...arguments_: any[]) => T, timeout: Duration | null, ): Promise<T[]> { if (timeout === null) { await this.waitFor(); } else { const success: boolean = await this.waitForTimeout(timeout); if (!success) { throw new HelmParserException(HelmExecution.MSG_TIMEOUT_ERROR); } } const exitCode: number = this.exitCode(); if (exitCode !== 0) { const stdOut: string = this.standardOutput(); const stdError: string = this.standardError(); throw new HelmExecutionException( exitCode, `Helm command failed with exit code ${exitCode}. Command: '${this.commandLine}'. Error: ${stdError}`, stdOut, stdError, ); } const output: string = this.standardOutput(); try { return JSON.parse(output) as T[]; } catch { throw new HelmParserException(HelmExecution.MSG_LIST_DESERIALIZATION_ERROR.replace('%s', responseClass.name)); } } /** * Executes the command and waits for completion. * @returns A promise that resolves when the command completes */ public async call(): Promise<void> { await this.callTimeout(null); } /** * Executes the command and waits for completion with a timeout. * @param timeout The maximum time to wait, or null to wait indefinitely * @returns A promise that resolves when the command completes or rejects on timeout */ public async callTimeout(timeout: Duration | null): Promise<void> { if (timeout === null) { await this.waitFor(); } else { const success: boolean = await this.waitForTimeout(timeout); if (!success) { throw new HelmParserException(HelmExecution.MSG_TIMEOUT_ERROR); } } const exitCode: number = this.exitCode(); if (exitCode !== 0) { const stdOut: string = this.standardOutput(); const stdError: string = this.standardError(); throw new HelmExecutionException( exitCode, `Helm command failed with exit code ${exitCode}. Command: '${this.commandLine}'. Error: ${stdError}`, stdOut, stdError, ); } } }