UNPKG

@hashgraph/solo

Version:

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

470 lines (396 loc) 18.3 kB
// 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}"`]); } }