@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
361 lines • 18.6 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import { PortUtilities } from '../../../../../business/utils/port-utilities.js';
import { PodReference } from '../../../resources/pod/pod-reference.js';
import { SoloError } from '../../../../../core/errors/solo-error.js';
import { sleep } from '../../../../../core/helpers.js';
import { Duration } from '../../../../../core/time/duration.js';
import { StatusCodes } from 'http-status-codes';
import { container } from 'tsyringe-neo';
import { V1Pod, V1Container, V1ExecAction, V1ObjectMeta, V1Probe, V1PodSpec, } from '@kubernetes/client-node';
import * as constants from '../../../../../core/constants.js';
import { InjectTokens } from '../../../../../core/dependency-injection/inject-tokens.js';
import { NamespaceName } from '../../../../../types/namespace/namespace-name.js';
import { ContainerName } from '../../../resources/container/container-name.js';
import { PodName } from '../../../resources/pod/pod-name.js';
import { K8ClientPodCondition } from './k8-client-pod-condition.js';
import { ShellRunner } from '../../../../../core/shell-runner.js';
import chalk from 'chalk';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import find from 'find-process';
export class K8ClientPod {
podReference;
pods;
kubeClient;
kubeConfig;
kubectlInstallationDirectory;
labels;
startupProbeCommand;
containerName;
containerImage;
containerCommand;
conditions;
podIp;
creationTimestamp;
deletionTimestamp;
logger;
constructor(podReference, pods, kubeClient, kubeConfig, kubectlInstallationDirectory, labels, startupProbeCommand, containerName, containerImage, containerCommand, conditions, podIp, creationTimestamp, deletionTimestamp) {
this.podReference = podReference;
this.pods = pods;
this.kubeClient = kubeClient;
this.kubeConfig = kubeConfig;
this.kubectlInstallationDirectory = kubectlInstallationDirectory;
this.labels = labels;
this.startupProbeCommand = startupProbeCommand;
this.containerName = containerName;
this.containerImage = containerImage;
this.containerCommand = containerCommand;
this.conditions = conditions;
this.podIp = podIp;
this.creationTimestamp = creationTimestamp;
this.deletionTimestamp = deletionTimestamp;
this.logger = container.resolve(InjectTokens.SoloLogger);
}
async killPod() {
try {
await this.kubeClient.deleteNamespacedPod({
name: this.podReference.name.toString(),
namespace: this.podReference.namespace.toString(),
gracePeriodSeconds: 1,
});
let podExists = true;
while (podExists) {
const pod = await this.pods.read(this.podReference);
if (pod?.deletionTimestamp) {
await sleep(Duration.ofSeconds(1));
}
else {
podExists = false;
}
}
}
catch (error) {
const errorMessage = `Failed to delete pod ${this.podReference.name.name} in namespace ${this.podReference.namespace}: ${error.message}`;
if (error.body?.code === StatusCodes.NOT_FOUND || error.response?.body?.code === StatusCodes.NOT_FOUND) {
this.logger.info(`Pod not found: ${errorMessage}`, error);
return;
}
throw new SoloError(errorMessage, error);
}
}
/**
* Forward a local port to a port on the pod
* @param localPort The local port to forward from
* @param podPort The pod port to forward to
* @param reuse - if true, reuse the port number from previous port forward operation
* @param persist - if true, errors in port-forwarding will restart the port-forwarding, even after ts process has ended
* @param isRetry
* @returns Promise resolving to the port forwarder server when not detached,
* or the port number (which may differ from localPort if it was in use) when detached
*/
async portForward(localPort, podPort, reuse, persist = false, externalAddress, isRetry = false) {
let availablePort = localPort;
const localBindAddress = externalAddress || constants.LOCAL_HOST;
try {
// first use http.request(url[, options][, callback]) GET method against localhost:localPort to kill any pre-existing
// port-forward that is no longer active. It doesn't matter what the response is.
const url = `http://${constants.LOCAL_HOST}:${localPort}`;
await new Promise((resolve) => {
http
.request(url, { method: 'GET' }, (response) => {
response.on('data', () => {
// do nothing
});
response.on('end', () => {
resolve();
});
})
.on('close', () => {
resolve();
})
.on('timeout', () => {
resolve();
})
.on('information', () => {
resolve();
})
.on('error', () => {
resolve();
})
.setTimeout(Duration.ofMinutes(5).toMillis())
.end();
});
this.logger.debug(`Returned from http request against http://${constants.LOCAL_HOST}:${localPort}`);
let matchedProcesses = [];
if (reuse) {
try {
matchedProcesses = await this.searchProcessListCommandByStrings([
'port-forward',
this.podReference.name.toString(),
]);
}
catch (error) {
throw new SoloError(`process list for port-forward failed. Error: ${error instanceof Error ? error.message : String(error)}`);
}
// Reuse an existing port-forward when at least one matching process is running.
if (matchedProcesses.length > 0) {
// Extract local port number from command output.
// Persist mode commands can have extra trailing args (e.g. kubectl path),
// so do not assume the last token is local:remote.
const portMappingPattern = /^(\d{1,5}):(\d{1,5})$/;
let parsedPort;
for (const process of matchedProcesses) {
if (!process.cmd) {
continue;
}
const tokens = process.cmd.split(/\s+/).filter(Boolean);
for (const token of tokens) {
const match = token.match(portMappingPattern);
if (match) {
const localPortCandidate = Number.parseInt(match[1], 10);
if (!Number.isNaN(localPortCandidate) && localPortCandidate > 0 && localPortCandidate <= 65_535) {
parsedPort = localPortCandidate;
break;
}
}
}
if (parsedPort !== undefined) {
break;
}
}
if (parsedPort !== undefined) {
availablePort = parsedPort;
this.logger.info(`Reuse already enabled port ${availablePort}`);
// port forward already enabled
return availablePort;
}
this.logger.warn(`Unable to extract reusable local port from existing port-forward command(s): ${matchedProcesses
.map((process) => process.cmd)
.join(' | ')}`);
}
}
// Find an available port starting from localPort with a 30-second timeout
availablePort = await PortUtilities.findAvailablePort(localPort, Duration.ofSeconds(30).toMillis(), this.logger);
if (availablePort === localPort) {
this.logger.showUser(chalk.yellow(`Using requested port ${localPort}`));
}
else {
this.logger.showUser(chalk.yellow(`Using available port ${availablePort}`));
}
this.logger.debug(`Creating port-forwarder for ${this.podReference.name}:${podPort} -> ${localBindAddress}:${availablePort}`);
this.logger.warn('Port-forwarding in detached mode has to be manually stopped or will stop when the Kubernetes pod it ', 'is connected to terminates.');
// If the persist flag is set, we need to run the port-forward in a detached process that restarts on failure even after the typescript process ends.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// When running via tsx (dev/test), __filename ends in .ts; use tsx to run the .ts source.
// In a compiled build it ends in .js; use node to run the compiled .js.
const isTsx = __filename.endsWith('.ts');
const persistScriptExtension = isTsx ? '.ts' : '.js';
const persistCmd = isTsx ? 'tsx' : 'node';
const persistPortForwardScriptPath = path.resolve(__dirname, `persist-port-forward${persistScriptExtension}`);
let cmd;
let cmdArguments;
if (persist) {
cmd = persistCmd;
cmdArguments = [
persistPortForwardScriptPath,
this.podReference.namespace.name,
`pods/${this.podReference.name}`,
this.kubeConfig.currentContext,
`${availablePort}:${podPort}`,
constants.KUBECTL,
this.kubectlInstallationDirectory,
localBindAddress,
'&',
];
}
else {
cmd = constants.KUBECTL;
cmdArguments = [
'port-forward',
'-n',
this.podReference.namespace.name,
'--address',
localBindAddress,
'--context',
this.kubeConfig.currentContext,
`pods/${this.podReference.name}`,
`${availablePort}:${podPort}`,
];
}
if (os.platform() === 'win32') {
const argumentsLength = cmdArguments.length;
cmdArguments = cmdArguments.map((anArgument, index) => {
if (index < argumentsLength - 1) {
return `"${anArgument}",`;
}
return `"${anArgument}"`;
});
cmdArguments = [
'Start-Process',
'-FilePath',
`"${cmd}"`,
'-WindowStyle',
'Hidden',
'-ArgumentList',
...cmdArguments,
];
cmd = 'powershell.exe';
}
await new ShellRunner().run(cmd, cmdArguments, true, true, {
PATH: `${this.kubectlInstallationDirectory}${path.delimiter}${process.env.PATH}`,
});
return availablePort;
}
catch (error) {
if (os.platform() === 'win32' && !isRetry && error?.message?.includes('listen EACCES')) {
// handle the case where port forwarding fails on Windows due to an issue with the WinNAT service.
// Restarting the WinNAT service can resolve the issue, and then we can retry starting the port forwarder.
// Example: listen EACCES: permission denied 127.0.0.1:50211
try {
await new ShellRunner().run('net stop winnat');
}
catch (stopError) {
const errorMessage = `Failed to stop WinNAT service: ${stopError.message}. Please open an administrator level terminal on Windows` +
'and run:\nnet stop winnat && net start winnat\nto attempt to resolve port forwarding issues. Run the corresponding ' +
'Solo destroy command and try your Solo deploy commands again. If you are unable to run as administrator, ' +
'you may try rebooting your machine to resolve the issue.';
this.logger.error(errorMessage, stopError);
throw new SoloError(errorMessage, stopError);
}
await new ShellRunner().run('net start winnat');
this.logger.warn('Restarted WinNAT service to recover from port forwarding failure on Windows');
await sleep(Duration.ofSeconds(5)); // wait a bit for the service to restart before retrying
return await this.portForward(localPort, podPort, reuse, persist, externalAddress, true);
}
const message = `failed to start port-forwarder [${this.podReference.name}:${podPort} -> ${localBindAddress}:${availablePort}]: ${error.message}`;
throw new SoloError(message, error);
}
}
async searchProcessListCommandByStrings(substringsToMatch) {
let matchedProcesses = [];
const findConfig = {
skipSelf: true,
};
const processes = await find('name', substringsToMatch.shift(), findConfig);
for (const substring of substringsToMatch) {
matchedProcesses = processes.filter((p) => {
this.logger.debug(`Checking process PID ${p.pid} with p=${JSON.stringify(p)} for substring '${substring}'`);
return p.cmd && p.cmd.includes(substring);
});
}
for (const process of matchedProcesses) {
this.logger.debug(`Found process with PID ${process.pid} and command ${process.cmd}`);
}
return matchedProcesses;
}
async stopPortForward(port) {
if (!port) {
return;
}
this.logger.showUser(chalk.yellow(`Stopping port-forward for port [${port}]`));
try {
let matchedProcesses = await this.searchProcessListCommandByStrings(['port-forward', `${port}:`]);
try {
matchedProcesses = await this.searchProcessListCommandByStrings(['port-forward', `${port}:`]);
}
catch (error) {
throw new SoloError(`process list for port-forward failed. Error: ${error instanceof Error ? error.message : String(error)}`);
}
this.logger.debug(`Found ${matchedProcesses.length} processes matching port-forward and port ${port}`);
// if length of matchedProcesses is 0 then could not find port forward running for this port
if (!matchedProcesses || matchedProcesses.length === 0) {
this.logger.debug(`No port-forward processes found for port ${port}`);
return;
}
// Extract PIDs and kill the processes
for (const pid of matchedProcesses.map((p) => p.pid)) {
try {
process.kill(pid, 'SIGTERM');
this.logger.debug(`Successfully sent SIGTERM to PID: ${pid}`);
// Wait a moment for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 1000));
const foundProcess = await find('pid', pid.toString());
if (foundProcess.length > 0) {
this.logger.debug(`Process with PID ${pid} is still running after SIGTERM, attempting to kill with SIGKILL`);
process.kill(pid, 'SIGKILL');
}
}
catch (killError) {
this.logger.warn(`Failed to kill process ${pid}: ${killError.message}`);
}
}
this.logger.debug(`Finished stopping port-forwarder for port [${port}]`);
}
catch (error) {
const errorMessage = `Error stopping port-forward for port ${port}: ${error.message}`;
this.logger.error(errorMessage);
throw new SoloError(errorMessage, error);
}
}
static toV1Pod(pod) {
const v1Metadata = new V1ObjectMeta();
v1Metadata.name = pod.podReference.name.toString();
v1Metadata.namespace = pod.podReference.namespace.toString();
v1Metadata.labels = pod.labels;
const v1ExecAction = new V1ExecAction();
v1ExecAction.command = pod.startupProbeCommand;
const v1Probe = new V1Probe();
v1Probe.exec = v1ExecAction;
const v1Container = new V1Container();
v1Container.name = pod.containerName.name;
v1Container.image = pod.containerImage;
v1Container.command = pod.containerCommand;
v1Container.startupProbe = v1Probe;
const v1Spec = new V1PodSpec();
v1Spec.containers = [v1Container];
const v1Pod = new V1Pod();
v1Pod.metadata = v1Metadata;
v1Pod.spec = v1Spec;
return v1Pod;
}
static fromV1Pod(v1Pod, pods, coreV1Api, kubeConfig, kubectlInstallationDirectory) {
if (!v1Pod) {
return undefined;
}
return new K8ClientPod(PodReference.of(NamespaceName.of(v1Pod.metadata?.namespace), PodName.of(v1Pod.metadata?.name)), pods, coreV1Api, kubeConfig, kubectlInstallationDirectory, v1Pod.metadata.labels, v1Pod.spec.containers[0]?.startupProbe?.exec?.command, ContainerName.of(v1Pod.spec.containers[0]?.name), v1Pod.spec.containers[0]?.image, v1Pod.spec.containers[0]?.command, v1Pod.status?.conditions?.map((condition) => new K8ClientPodCondition(condition.type, condition.status)), v1Pod.status?.podIP, v1Pod.metadata?.creationTimestamp ? new Date(v1Pod.metadata.creationTimestamp) : undefined, v1Pod.metadata.deletionTimestamp ? new Date(v1Pod.metadata.deletionTimestamp) : undefined);
}
}
//# sourceMappingURL=k8-client-pod.js.map