@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
354 lines • 18 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import { container } from 'tsyringe-neo';
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 { spawn } from 'node:child_process';
import { v4 as uuid4 } from 'uuid';
import * as tar from 'tar';
import { InjectTokens } from '../../../../../core/dependency-injection/inject-tokens.js';
import { sleep } from '../../../../../core/helpers.js';
import { Duration } from '../../../../../core/time/duration.js';
import * as constants from '../../../../../core/constants.js';
import { platform } from 'node:process';
import { PathEx } from '../../../../../business/utils/path-ex.js';
import eol from 'eol';
export class K8ClientContainer {
kubeConfig;
containerReference;
pods;
kubectlInstallationDirectory;
logger;
constructor(kubeConfig, containerReference, pods, kubectlInstallationDirectory) {
this.kubeConfig = kubeConfig;
this.containerReference = containerReference;
this.pods = pods;
this.kubectlInstallationDirectory = kubectlInstallationDirectory;
this.logger = container.resolve(InjectTokens.SoloLogger);
}
async getContext() {
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)
*/
async waitForPod(maxAttempts = 20, delayMs = 3000) {
const podName = this.containerReference.parentReference.name.toString();
try {
await this.pods.waitForPodByReference(this.containerReference.parentReference, maxAttempts, delayMs);
}
catch {
throw new IllegalArgumentError(`Invalid pod ${podName}`);
}
}
async execKubectl(arguments_, outputPassThroughStream, errorPassThroughStream) {
const context = await this.getContext();
const fullArguments = ['--context', context, ...arguments_];
this.logger.debug(`Executing kubectl with arguments: ${fullArguments.join(' ')}`);
return new Promise((resolve, reject) => {
const callMessage = `${constants.KUBECTL} ${fullArguments.join(' ')}`;
const childProcess = 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 = '';
let stderr = '';
childProcess.stdout.on('data', (chunk) => {
if (outputPassThroughStream) {
outputPassThroughStream.write(chunk);
}
stdout += chunk.toString();
});
childProcess.stderr.on('data', (chunk) => {
if (errorPassThroughStream) {
errorPassThroughStream.write(chunk);
}
stderr += chunk.toString();
});
childProcess.on('error', (error) => {
reject(new SoloError(`container call: ${callMessage}, failed to start: ${error?.message}`));
});
childProcess.on('close', (code) => {
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
*/
async execKubectlCp(source, destination, containerName, verifyPath, expectedSize) {
const maxAttempts = constants.CONTAINER_COPY_MAX_ATTEMPTS;
source = this.toKubectlSafePath(source);
destination = this.toKubectlSafePath(destination);
const arguments_ = ['cp', source, destination, '-c', containerName];
for (let attempt = 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.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));
}
}
}
toKubectlSafePath(path) {
// 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 = path.match(/^([a-zA-Z]):\\/);
if (driveLetterMatch) {
const driveLetter = driveLetterMatch[1].toLowerCase();
path = `//localhost/${driveLetter}$${path.slice(2)}`;
}
}
return path;
}
async copyFrom(sourcePath, destinationDirectory) {
const namespace = this.containerReference.parentReference.namespace;
const podName = this.containerReference.parentReference.name.toString();
const containerName = 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 = 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 = entries[0].name.indexOf(' -> ');
const targetSuffix = entries[0].name.slice(arrowIndex + 4);
const redirectSourcePath = `${path.dirname(sourcePath)}/${targetSuffix}`;
entries = await this.listDir(redirectSourcePath);
if (entries.length !== 1) {
throw new SoloError(`copyFrom: invalid source path: ${redirectSourcePath}`);
}
}
const sourceFileDesc = entries[0];
const sourceFileSize = Number.parseInt(sourceFileDesc.size, 10);
const resolvedRemotePath = sourceFileDesc.name;
const sourceFileName = path.basename(resolvedRemotePath);
const destinationPath = PathEx.join(destinationDirectory, sourceFileName);
this.logger.info(`copyFrom: beginning copy [container: ${containerName} ${namespace.name}/${podName}:${resolvedRemotePath} ${destinationPath}]`);
const remoteSource = `${namespace.name}/${podName}:${resolvedRemotePath}`;
await this.execKubectlCp(remoteSource, destinationPath, containerName, destinationPath, sourceFileSize);
return true;
}
async copyTo(sourcePath, destinationDirectory, filter) {
const namespace = this.containerReference.parentReference.namespace;
const podName = this.containerReference.parentReference.name.toString();
const containerName = 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 = `${namespace.name}/${podName}:${destinationDirectory}`;
this.logger.info(`copyTo: [srcPath=${sourcePath}, destDir=${destinationDirectory}] to ${remoteDestination} (container=${containerName})`);
let localPathToCopy = sourcePath;
let temporaryDirectory;
let temporaryTar;
try {
const sourceFileName = 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 = PathEx.join(temporaryDirectory, sourceFileName);
let content = fs.readFileSync(sourcePath, 'utf8');
// Convert CRLF to LF
content = eol.lf(content);
// Write back
fs.writeFileSync(temporarySourcePath, content);
localPathToCopy = temporarySourcePath;
}
if (filter) {
const sourceDirectory = 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
}
}
}
}
async execContainer(cmd, outputPassThroughStream, errorPassThroughStream) {
const namespace = this.containerReference.parentReference.namespace;
const podName = this.containerReference.parentReference.name.toString();
const containerName = this.containerReference.name.toString();
await this.waitForPod();
if (!cmd) {
throw new MissingArgumentError('command cannot be empty');
}
const command = Array.isArray(cmd) ? cmd : cmd.split(' ');
this.logger.info(`execContainer: beginning call [podName: ${podName} -n ${namespace.name} -c ${containerName} -- ${command.join(' ')}]`);
const arguments_ = ['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 = 30;
const retryableContainerNotReady = /(container not found|unable to upgrade connection)/i;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await this.execKubectl(arguments_, outputPassThroughStream, errorPassThroughStream);
}
catch (error) {
const message = 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(' ')}`);
}
async hasDir(destinationPath) {
const bashScript = `[[ -d "${destinationPath}" ]] && echo -n "true" || echo -n "false"`;
try {
const result = 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 = `[ -d "${destinationPath}" ] && echo -n "true" || echo -n "false"`;
const result = await this.execContainer(['/bin/sh', '-c', shScript]);
return result === 'true';
}
}
async hasFile(destinationPath, filters = {}) {
const parentDirectory = path.dirname(destinationPath);
const fileName = path.basename(destinationPath);
const filterMap = new Map(Object.entries(filters));
try {
const entries = await this.listDir(parentDirectory);
for (const item of entries) {
if (item.name === fileName && !item.directory) {
let found = 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;
}
async listDir(destinationPath) {
try {
const output = await this.execContainer(['ls', '-la', destinationPath]);
if (!output) {
return [];
}
// parse the output and return the entries
const items = [];
const lines = output.split('\n');
for (let line of lines) {
line = line.replaceAll(/\s+/g, '|');
const parts = line.split('|');
if (parts.length >= 9) {
let name = parts.at(-1);
// 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 = parts.length - 1; index > 8; index--) {
name = `${parts[index - 1]} ${name}`;
}
if (name !== '.' && name !== '..') {
const permission = parts[0];
const item = {
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);
}
}
async mkdir(destinationPath) {
return this.execContainer(['bash', '-c', `mkdir -p "${destinationPath}"`]);
}
}
//# sourceMappingURL=k8-client-container.js.map