@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
470 lines (396 loc) • 18.3 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import {container} from 'tsyringe-neo';
import {type Container} from '../../../resources/container/container.js';
import {type TDirectoryData} from '../../../t-directory-data.js';
import {type ContainerReference} from '../../../resources/container/container-reference.js';
import {IllegalArgumentError} from '../../../../../core/errors/illegal-argument-error.js';
import {MissingArgumentError} from '../../../../../core/errors/missing-argument-error.js';
import {SoloError} from '../../../../../core/errors/solo-error.js';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import {type ChildProcessByStdio, spawn} from 'node:child_process';
import {v4 as uuid4} from 'uuid';
import * as tar from 'tar';
import {type SoloLogger} from '../../../../../core/logging/solo-logger.js';
import {type KubeConfig} from '@kubernetes/client-node';
import {type Pods} from '../../../resources/pod/pods.js';
import {InjectTokens} from '../../../../../core/dependency-injection/inject-tokens.js';
import {type NamespaceName} from '../../../../../types/namespace/namespace-name.js';
import {type TarCreateFilter} from '../../../../../types/aliases.js';
import {type Context} from '../../../../../types/index.js';
import {sleep} from '../../../../../core/helpers.js';
import {Duration} from '../../../../../core/time/duration.js';
import type Stream from 'node:stream';
import * as constants from '../../../../../core/constants.js';
import type * as stream from 'node:stream';
import {platform} from 'node:process';
import {PathEx} from '../../../../../business/utils/path-ex.js';
import eol from 'eol';
export class K8ClientContainer implements Container {
private readonly logger: SoloLogger;
public constructor(
private readonly kubeConfig: KubeConfig,
private readonly containerReference: ContainerReference,
private readonly pods: Pods,
private readonly kubectlInstallationDirectory: string,
) {
this.logger = container.resolve(InjectTokens.SoloLogger);
}
private async getContext(): Promise<string> {
return this.kubeConfig.getCurrentContext();
}
/**
* Waits until the pod for this container reference is visible in the API before
* `copyTo`, `copyFrom`, or `execContainer`.
*
* Uses {@link Pods.waitForPodByReference} and maps failures to
* {@link IllegalArgumentError} with the pod name.
*
* @param maxAttempts - forwarded to {@link Pods.waitForPodByReference} (default 20)
* @param delayMs - forwarded to {@link Pods.waitForPodByReference} (default 3000 ms)
*/
private async waitForPod(maxAttempts: number = 20, delayMs: number = 3000): Promise<void> {
const podName: string = this.containerReference.parentReference.name.toString();
try {
await this.pods.waitForPodByReference(this.containerReference.parentReference, maxAttempts, delayMs);
} catch {
throw new IllegalArgumentError(`Invalid pod ${podName}`);
}
}
private async execKubectl(
arguments_: string[],
outputPassThroughStream?: stream.PassThrough,
errorPassThroughStream?: stream.PassThrough,
): Promise<string> {
const context: Context = await this.getContext();
const fullArguments: string[] = ['--context', context, ...arguments_];
this.logger.debug(`Executing kubectl with arguments: ${fullArguments.join(' ')}`);
return new Promise((resolve, reject): void => {
const callMessage: string = `${constants.KUBECTL} ${fullArguments.join(' ')}`;
const childProcess: ChildProcessByStdio<null, Stream.Readable, Stream.Readable> = spawn(
constants.KUBECTL,
fullArguments,
{
env: {...process.env, PATH: `${this.kubectlInstallationDirectory}${path.delimiter}${process.env.PATH}`},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: os.platform() === 'win32',
},
);
let stdout: string = '';
let stderr: string = '';
childProcess.stdout.on('data', (chunk): void => {
if (outputPassThroughStream) {
outputPassThroughStream.write(chunk);
}
stdout += chunk.toString();
});
childProcess.stderr.on('data', (chunk): void => {
if (errorPassThroughStream) {
errorPassThroughStream.write(chunk);
}
stderr += chunk.toString();
});
childProcess.on('error', (error): void => {
reject(new SoloError(`container call: ${callMessage}, failed to start: ${error?.message}`));
});
childProcess.on('close', (code): void => {
if (code === 0) {
resolve(stdout || stderr);
} else {
reject(new SoloError(`container call: ${callMessage}, failed with code ${code}: ${stderr || stdout}`));
}
});
});
}
/**
* Execute `kubectl cp` with retries and optional verification.
*
* @param source - kubectl cp source, e.g. `<ns>/<pod>:/path` or `/local/path`
* @param destination - kubectl cp destination, e.g. `/local/path` or `<ns>/<pod>:/path`
* @param containerName - name of the container for -c flag
* @param verifyPath - local filesystem path to verify after copy (usually the destination for copyFrom)
* @param expectedSize - optional expected file size for strict verification
*/
private async execKubectlCp(
source: string,
destination: string,
containerName: string,
verifyPath: string,
expectedSize?: number,
): Promise<void> {
const maxAttempts: number = constants.CONTAINER_COPY_MAX_ATTEMPTS;
source = this.toKubectlSafePath(source);
destination = this.toKubectlSafePath(destination);
const arguments_: string[] = ['cp', source, destination, '-c', containerName];
for (let attempt: number = 1; attempt <= maxAttempts; attempt++) {
try {
await this.execKubectl(arguments_);
if (!fs.existsSync(verifyPath)) {
throw new SoloError(`copy failed: missing file at ${verifyPath}`);
}
const stat: fs.Stats = fs.statSync(verifyPath);
if (expectedSize !== undefined && stat.size !== expectedSize) {
throw new SoloError(
`copy verification failed: expected size ${expectedSize} but found ${stat.size} at ${verifyPath}`,
);
}
return;
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
// backoff between retries
await sleep(Duration.ofMillis(attempt * constants.CONTAINER_COPY_BACKOFF_MS));
}
}
}
private toKubectlSafePath(path: string): string {
// kubectl cp does not handle windows path with drive letters because of the colon, so we need to convert
// C:\path\to\file\file.txt to the format \\localhost\c$\path\to\file\file.txt
if (platform === 'win32') {
const driveLetterMatch: RegExpMatchArray | null = path.match(/^([a-zA-Z]):\\/);
if (driveLetterMatch) {
const driveLetter: string = driveLetterMatch[1].toLowerCase();
path = `//localhost/${driveLetter}$${path.slice(2)}`;
}
}
return path;
}
public async copyFrom(sourcePath: string, destinationDirectory: string): Promise<boolean> {
const namespace: NamespaceName = this.containerReference.parentReference.namespace;
const podName: string = this.containerReference.parentReference.name.toString();
const containerName: string = this.containerReference.name.toString();
sourcePath = this.toKubectlSafePath(sourcePath);
destinationDirectory = this.toKubectlSafePath(destinationDirectory);
await this.waitForPod();
if (!fs.existsSync(destinationDirectory)) {
throw new SoloError(`invalid destination path: ${destinationDirectory}`);
}
this.logger.info(
`copyFrom: [srcPath=${sourcePath}, destDir=${destinationDirectory}] from ${namespace.name}/${podName}:${containerName}`,
);
let entries: TDirectoryData[] = await this.listDir(sourcePath);
if (entries.length !== 1) {
throw new SoloError(`copyFrom: invalid source path: ${sourcePath}`);
}
// handle symbolic link
if (entries[0].name.includes(' -> ')) {
const arrowIndex: number = entries[0].name.indexOf(' -> ');
const targetSuffix: string = entries[0].name.slice(arrowIndex + 4);
const redirectSourcePath: string = `${path.dirname(sourcePath)}/${targetSuffix}`;
entries = await this.listDir(redirectSourcePath);
if (entries.length !== 1) {
throw new SoloError(`copyFrom: invalid source path: ${redirectSourcePath}`);
}
}
const sourceFileDesc: TDirectoryData = entries[0];
const sourceFileSize: number = Number.parseInt(sourceFileDesc.size, 10);
const resolvedRemotePath: string = sourceFileDesc.name;
const sourceFileName: string = path.basename(resolvedRemotePath);
const destinationPath: string = PathEx.join(destinationDirectory, sourceFileName);
this.logger.info(
`copyFrom: beginning copy [container: ${containerName} ${namespace.name}/${podName}:${resolvedRemotePath} ${destinationPath}]`,
);
const remoteSource: string = `${namespace.name}/${podName}:${resolvedRemotePath}`;
await this.execKubectlCp(remoteSource, destinationPath, containerName, destinationPath, sourceFileSize);
return true;
}
public async copyTo(sourcePath: string, destinationDirectory: string, filter?: TarCreateFilter): Promise<boolean> {
const namespace: NamespaceName = this.containerReference.parentReference.namespace;
const podName: string = this.containerReference.parentReference.name.toString();
const containerName: string = this.containerReference.name.toString();
await this.waitForPod();
if (!(await this.hasDir(destinationDirectory))) {
throw new SoloError(`invalid destination path: ${destinationDirectory}`);
}
if (!fs.existsSync(sourcePath)) {
throw new SoloError(`invalid source path: ${sourcePath}`);
}
const remoteDestination: string = `${namespace.name}/${podName}:${destinationDirectory}`;
this.logger.info(
`copyTo: [srcPath=${sourcePath}, destDir=${destinationDirectory}] to ${remoteDestination} (container=${containerName})`,
);
let localPathToCopy: string = sourcePath;
let temporaryDirectory: string | undefined;
let temporaryTar: string | undefined;
try {
const sourceFileName: string = path.basename(sourcePath);
if (sourceFileName.endsWith('.sh') && os.platform() === 'win32') {
// For text files on Windows, convert line endings to LF to avoid issues in Linux containers.
temporaryDirectory = fs.mkdtempSync(PathEx.join(os.tmpdir(), 'solo-kubectl-cp-src-'));
const temporarySourcePath: string = PathEx.join(temporaryDirectory, sourceFileName);
let content: string = fs.readFileSync(sourcePath, 'utf8');
// Convert CRLF to LF
content = eol.lf(content);
// Write back
fs.writeFileSync(temporarySourcePath, content);
localPathToCopy = temporarySourcePath;
}
if (filter) {
const sourceDirectory: string = path.dirname(sourcePath);
temporaryDirectory = fs.mkdtempSync(PathEx.join(os.tmpdir(), 'solo-kubectl-cp-src-'));
temporaryTar = PathEx.join(temporaryDirectory, `${sourceFileName}-${uuid4()}.tar`);
// Create a filtered tarball
await tar.c({file: temporaryTar, cwd: sourceDirectory, filter}, [sourceFileName]);
// Extract the filtered content into the temporaryDirectory.
await tar.x({file: temporaryTar, cwd: temporaryDirectory});
localPathToCopy = PathEx.join(temporaryDirectory, sourceFileName);
if (!fs.existsSync(localPathToCopy)) {
throw new SoloError(`filtered source path does not exist: ${localPathToCopy}`);
}
}
this.logger.info(`copyTo: beginning copy [container: ${containerName} ${localPathToCopy} ${remoteDestination}]`);
await this.execKubectlCp(localPathToCopy, remoteDestination, containerName, localPathToCopy);
return true;
} finally {
// Clean up temp artifacts if any.
if (temporaryTar && fs.existsSync(temporaryTar)) {
try {
fs.rmSync(temporaryTar);
} catch {
// ignore
}
}
if (temporaryDirectory && fs.existsSync(temporaryDirectory)) {
try {
fs.rmSync(temporaryDirectory, {recursive: true, force: true});
} catch {
// ignore
}
}
}
}
public async execContainer(
cmd: string | string[],
outputPassThroughStream?: stream.PassThrough,
errorPassThroughStream?: stream.PassThrough,
): Promise<string> {
const namespace: NamespaceName = this.containerReference.parentReference.namespace;
const podName: string = this.containerReference.parentReference.name.toString();
const containerName: string = this.containerReference.name.toString();
await this.waitForPod();
if (!cmd) {
throw new MissingArgumentError('command cannot be empty');
}
const command: string[] = Array.isArray(cmd) ? cmd : cmd.split(' ');
this.logger.info(
`execContainer: beginning call [podName: ${podName} -n ${namespace.name} -c ${containerName} -- ${command.join(' ')}]`,
);
const arguments_: string[] = ['exec', podName, '-n', namespace.name, '-c', containerName, '--', ...command];
// During rolling restarts, kubelet may report "container not found" for a few seconds
// even when the pod object is present. Retry that transient state.
const maxAttempts: number = 30;
const retryableContainerNotReady: RegExp = /(container not found|unable to upgrade connection)/i;
for (let attempt: number = 1; attempt <= maxAttempts; attempt++) {
try {
return await this.execKubectl(arguments_, outputPassThroughStream, errorPassThroughStream);
} catch (error) {
const message: string = error instanceof Error ? error.message : `${error}`;
if (!retryableContainerNotReady.test(message) || attempt === maxAttempts) {
throw error;
}
await sleep(Duration.ofMillis(1000));
}
}
throw new SoloError(
`container call failed after retries: ${podName} -n ${namespace.name} -c ${containerName} -- ${command.join(' ')}`,
);
}
public async hasDir(destinationPath: string): Promise<boolean> {
const bashScript: string = `[[ -d "${destinationPath}" ]] && echo -n "true" || echo -n "false"`;
try {
const result: string = await this.execContainer(['bash', '-c', bashScript]);
return result === 'true';
} catch (error) {
this.logger.debug(
`hasDir failed using bash for ${this.containerReference.parentReference.name}:${this.containerReference.name}, retrying with /bin/sh`,
error,
);
const shScript: string = `[ -d "${destinationPath}" ] && echo -n "true" || echo -n "false"`;
const result: string = await this.execContainer(['/bin/sh', '-c', shScript]);
return result === 'true';
}
}
public async hasFile(destinationPath: string, filters: object = {}): Promise<boolean> {
const parentDirectory: string = path.dirname(destinationPath);
const fileName: string = path.basename(destinationPath);
const filterMap: Map<string, string> = new Map(Object.entries(filters));
try {
const entries: TDirectoryData[] = await this.listDir(parentDirectory);
for (const item of entries) {
if (item.name === fileName && !item.directory) {
let found: boolean = true;
for (const [field, value] of filterMap.entries()) {
this.logger.debug(
`Checking file ${this.containerReference.parentReference.name}:${this.containerReference.name} ${destinationPath}; ${field} expected ${value}, found ${item[field]}`,
{filters},
);
if (`${value}` !== `${item[field]}`) {
found = false;
break;
}
}
if (found) {
this.logger.debug(
`File check succeeded ${this.containerReference.parentReference.name}:${this.containerReference.name} ${destinationPath}`,
{
filters,
},
);
return true;
}
}
}
} catch (error) {
throw new SoloError(
`unable to check file in '${this.containerReference.parentReference.name}':${this.containerReference.name}' - ${destinationPath}: ${error.message}`,
error,
);
}
return false;
}
public async listDir(destinationPath: string): Promise<TDirectoryData[]> {
try {
const output: string = await this.execContainer(['ls', '-la', destinationPath]);
if (!output) {
return [];
}
// parse the output and return the entries
const items: TDirectoryData[] = [];
const lines: string[] = output.split('\n');
for (let line of lines) {
line = line.replaceAll(/\s+/g, '|');
const parts: string[] = line.split('|');
if (parts.length >= 9) {
let name: string = parts.at(-1) as string;
// handle unique file format (without single quotes): 'usedAddressBook_vHederaSoftwareVersion{hapiVersion=v0.53.0, servicesVersion=v0.53.0}_2024-07-30-20-39-06_node_0.txt.debug'
for (let index: number = parts.length - 1; index > 8; index--) {
name = `${parts[index - 1]} ${name}`;
}
if (name !== '.' && name !== '..') {
const permission: string = parts[0];
const item: TDirectoryData = {
directory: permission[0] === 'd',
owner: parts[2],
group: parts[3],
size: parts[4],
modifiedAt: `${parts[5]} ${parts[6]} ${parts[7]}`,
name,
};
items.push(item);
}
}
}
return items;
} catch (error) {
throw new SoloError(
`unable to check path in '${this.containerReference.parentReference.name}':${this.containerReference.name}' - ${destinationPath}: ${error.message}`,
error,
);
}
}
public async mkdir(destinationPath: string): Promise<string> {
return this.execContainer(['bash', '-c', `mkdir -p "${destinationPath}"`]);
}
}