@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
298 lines (266 loc) • 9.83 kB
text/typescript
// 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,
);
}
}
}