UNPKG

@hashgraph/solo

Version:

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

411 lines (352 loc) 14 kB
// SPDX-License-Identifier: Apache-2.0 /** * Persistently port-forward a local port to a port on a Kubernetes pod. * This solves an issue where a detached port-forward can be terminated by network issues. * Usage: persist-port-forward <namespace> <pod> <context> <port_map> [kubectl_executable] [kubectl_installation_dir] * Note: <port_map> needs to be in the format <local>:<remote>. */ import {spawn, type ChildProcess} from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; // eslint-disable-next-line unicorn/no-unreadable-array-destructuring const [, , NAMESPACE, POD, CONTEXT, PORT_MAP, KUBECTL_EXECUTABLE, KUBECTL_INSTALLATION_DIRECTORY, EXTERNAL_ADDRESS] = process.argv; if (!NAMESPACE || !POD || !CONTEXT || !PORT_MAP) { console.error( 'Usage: persist-port-forward <namespace> <pod> <context> <port_map> [kubectl_executable] [kubectl_installation_dir]', ); // eslint-disable-next-line unicorn/no-process-exit,n/no-process-exit process.exit(2); } const MIN_BACKOFF: number = 1; // seconds const MAX_BACKOFF: number = 60; // seconds const POD_EXISTENCE_POLL_INTERVAL_SECONDS: number = 5; const POD_MISSING_EXIT_THRESHOLD: number = 3; const CLUSTER_UNAVAILABLE_EXIT_THRESHOLD: number = 3; let backoff: number = MIN_BACKOFF; let child: ChildProcess | undefined; let stopping: boolean = false; let exitForMissingTarget: boolean = false; let consecutivePodMissingChecks: number = 0; let consecutiveClusterUnavailableChecks: number = 0; let targetResource: string = POD; interface CommandResult { code: number; stdout: string; stderr: string; } interface ExecuteKubectlOptions { captureOutput: boolean; trackAsChild: boolean; } function isMissingContextOrNamespaceError(message: string): boolean { const errorText: string = message.toLowerCase(); const missingContext: boolean = (errorText.includes('context') && errorText.includes('does not exist')) || (errorText.includes('no context exists with the name') && errorText.includes(CONTEXT.toLowerCase())); const missingNamespace: boolean = (errorText.includes('namespaces') && errorText.includes('not found')) || (errorText.includes('namespace') && errorText.includes('does not exist')); return missingContext || missingNamespace; } function isMissingPodError(message: string): boolean { const errorText: string = message.toLowerCase(); return ( errorText.includes('notfound') || (errorText.includes('not found') && (errorText.includes('pods') || errorText.includes('pod'))) ); } function extractPodName(resource: string): string | undefined { const podPrefix: string = 'pods/'; if (!resource.startsWith(podPrefix)) { return undefined; } return resource.slice(podPrefix.length); } function derivePodWorkloadPrefix(podName: string): string { if (!podName.includes('-')) { return podName; } const deploymentStyleMatch: RegExpMatchArray | null = podName.match(/^(.*)-[a-f0-9]{9,10}-[a-z0-9]{5}$/); if (deploymentStyleMatch?.[1]) { return deploymentStyleMatch[1]; } const statefulSetStyleMatch: RegExpMatchArray | null = podName.match(/^(.*)-\d+$/); if (statefulSetStyleMatch?.[1]) { return statefulSetStyleMatch[1]; } return podName; } async function findReplacementPodResource(kubectlInstallationDirectory: string): Promise<string | undefined> { const currentPodName: string | undefined = extractPodName(targetResource); if (!currentPodName) { return undefined; } const workloadPrefix: string = derivePodWorkloadPrefix(currentPodName); const currentPodSegmentCount: number = currentPodName.split('-').length; const runningPodsResult: CommandResult = await executeKubectl( ['--context', CONTEXT, '-n', NAMESPACE, 'get', 'pods', '--field-selector=status.phase=Running', '-o', 'name'], kubectlInstallationDirectory, {captureOutput: true, trackAsChild: false}, ); if (runningPodsResult.code !== 0) { return undefined; } const replacementPodName: string | undefined = runningPodsResult.stdout .split('\n') .map((line: string): string => line.trim()) .filter((line: string): boolean => line.startsWith('pod/')) .map((line: string): string => line.replace(/^pod\//, '')) .find( (podName: string): boolean => podName !== currentPodName && podName.split('-').length === currentPodSegmentCount && (podName === workloadPrefix || podName.startsWith(`${workloadPrefix}-`)), ); if (!replacementPodName) { return undefined; } return `pods/${replacementPodName}`; } async function hasReplacementPodCandidate(kubectlInstallationDirectory: string): Promise<boolean> { const currentPodName: string | undefined = extractPodName(targetResource); if (!currentPodName) { return false; } const workloadPrefix: string = derivePodWorkloadPrefix(currentPodName); const currentPodSegmentCount: number = currentPodName.split('-').length; const podsResult: CommandResult = await executeKubectl( ['--context', CONTEXT, '-n', NAMESPACE, 'get', 'pods', '-o', 'name'], kubectlInstallationDirectory, {captureOutput: true, trackAsChild: false}, ); if (podsResult.code !== 0) { return false; } return podsResult.stdout .split('\n') .map((line: string): string => line.trim()) .filter((line: string): boolean => line.startsWith('pod/')) .map((line: string): string => line.replace(/^pod\//, '')) .some( (podName: string): boolean => podName !== currentPodName && podName.split('-').length === currentPodSegmentCount && (podName === workloadPrefix || podName.startsWith(`${workloadPrefix}-`)), ); } function isClusterUnavailableError(message: string): boolean { const errorText: string = message.toLowerCase(); const genericConnectionFailure: boolean = errorText.includes('unable to connect to the server') || errorText.includes('the connection to the server') || errorText.includes('server has asked for the client to provide credentials'); const dialTcpFailure: boolean = errorText.includes('dial tcp') && (errorText.includes('connection refused') || errorText.includes('no such host') || errorText.includes('i/o timeout')); return genericConnectionFailure || dialTcpFailure; } async function executeKubectl( commandArguments: string[], kubectlInstallationDirectory: string, options: ExecuteKubectlOptions, ): Promise<CommandResult> { return await new Promise((resolve): void => { const stdoutChunks: string[] = []; const stderrChunks: string[] = []; const kubectlCommand: string = KUBECTL_EXECUTABLE || 'kubectl'; const kubectlProcess: ChildProcess = spawn(kubectlCommand, commandArguments, { env: {...process.env, PATH: `${kubectlInstallationDirectory}${path.delimiter}${process.env.PATH}`}, stdio: options.captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: os.platform() === 'win32', }); if (options.trackAsChild) { child = kubectlProcess; } kubectlProcess.stdout?.on('data', (chunk: Buffer): void => { stdoutChunks.push(chunk.toString()); }); kubectlProcess.stderr?.on('data', (chunk: Buffer): void => { stderrChunks.push(chunk.toString()); }); kubectlProcess.on('error', (error): void => { resolve({ code: 1, stdout: stdoutChunks.join(''), stderr: `${stderrChunks.join('')}\n${String(error)}`, }); }); kubectlProcess.on('close', (code, signal): void => { if (options.trackAsChild && child?.pid === kubectlProcess.pid) { child = undefined; } const stderrOutput: string = stderrChunks.join(''); const signalMessage: string = signal ? `\nProcess terminated by signal: ${signal}` : ''; let exitCode: number = 1; if (typeof code === 'number') { exitCode = code; } else if (stopping && (signal === 'SIGTERM' || signal === 'SIGINT')) { exitCode = 0; } resolve({ code: exitCode, stdout: stdoutChunks.join(''), stderr: `${stderrOutput}${signalMessage}`, }); }); }); } /** * Check whether the original pod target still exists. * If the pod has been removed, this persistent process should stop and not auto-restart. */ async function shouldExitForMissingTarget(kubectlInstallationDirectory: string): Promise<boolean> { // The pod argument is the original pod reference this process was started for. const podResult: CommandResult = await executeKubectl( ['--context', CONTEXT, '-n', NAMESPACE, 'get', targetResource, '-o', 'name'], kubectlInstallationDirectory, {captureOutput: true, trackAsChild: false}, ); if (podResult.code !== 0) { const combinedPodError: string = `${podResult.stderr}\n${podResult.stdout}`.trim(); if (isMissingContextOrNamespaceError(combinedPodError)) { console.error( `Stopping persistent port-forward: original target/context is no longer available (${combinedPodError || 'unknown kubectl error'})`, ); return true; } if (isMissingPodError(combinedPodError)) { const replacementResource: string | undefined = await findReplacementPodResource(kubectlInstallationDirectory); if (replacementResource) { console.error(`Switching persistent port-forward target from ${targetResource} to ${replacementResource}`); targetResource = replacementResource; consecutivePodMissingChecks = 0; if (child) { try { child.kill('SIGTERM'); } catch { // ignore } } return false; } if (await hasReplacementPodCandidate(kubectlInstallationDirectory)) { consecutivePodMissingChecks = 0; return false; } consecutivePodMissingChecks += 1; if (consecutivePodMissingChecks >= POD_MISSING_EXIT_THRESHOLD) { console.error( `Stopping persistent port-forward: target pod appears gone after ${consecutivePodMissingChecks} checks (${combinedPodError || 'unknown kubectl error'})`, ); return true; } return false; } if (isClusterUnavailableError(combinedPodError)) { consecutiveClusterUnavailableChecks += 1; if (consecutiveClusterUnavailableChecks >= CLUSTER_UNAVAILABLE_EXIT_THRESHOLD) { console.error( `Stopping persistent port-forward: cluster appears unavailable after ${consecutiveClusterUnavailableChecks} checks (${combinedPodError || 'unknown kubectl error'})`, ); return true; } return false; } consecutivePodMissingChecks = 0; consecutiveClusterUnavailableChecks = 0; return false; } consecutivePodMissingChecks = 0; consecutiveClusterUnavailableChecks = 0; return false; } function runKubectl(kubectlInstallationDirectory: string): Promise<number> { const arguments_: string[] = ['port-forward', '-n', NAMESPACE]; if (EXTERNAL_ADDRESS) { arguments_.push('--address', EXTERNAL_ADDRESS); } if (CONTEXT) { arguments_.push('--context', CONTEXT); } const [LOCAL, REMOTE] = PORT_MAP.split(':'); arguments_.push(targetResource, `${LOCAL}:${REMOTE}`); console.error(`Starting kubectl ${arguments_.join(' ')}`); return executeKubectl(arguments_, kubectlInstallationDirectory, {captureOutput: false, trackAsChild: true}).then( (result: CommandResult): number => { if (result.code !== 0) { console.error('Failed to start kubectl:', result.stderr || `exit code ${result.code}`); } return result.code; }, ); } function sleepSeconds(s: number): Promise<void> { // eslint-disable-next-line unicorn/prevent-abbreviations return new Promise((res): NodeJS.Timeout => setTimeout(res, s * 1000)); } async function runKubectlUntilPodMissing(kubectlInstallationDirectory: string): Promise<number> { const TICK: unique symbol = Symbol('tick'); const kubectlRunPromise: Promise<number> = runKubectl(kubectlInstallationDirectory); while (!stopping && !exitForMissingTarget) { const result: number | typeof TICK = await Promise.race<number | typeof TICK>([ kubectlRunPromise, sleepSeconds(POD_EXISTENCE_POLL_INTERVAL_SECONDS).then((): typeof TICK => TICK), ]); if (result !== TICK) { return result; } if (await shouldExitForMissingTarget(kubectlInstallationDirectory)) { exitForMissingTarget = true; if (child) { try { child.kill('SIGTERM'); } catch { // ignore } } break; } } return await kubectlRunPromise; } async function main(): Promise<void> { const kubectlInstallationDirectory: string = KUBECTL_INSTALLATION_DIRECTORY || ''; while (!stopping && !exitForMissingTarget) { if (await shouldExitForMissingTarget(kubectlInstallationDirectory)) { exitForMissingTarget = true; break; } const rc: number = await runKubectlUntilPodMissing(kubectlInstallationDirectory); if (stopping || exitForMissingTarget) { break; } console.error(`kubectl exited with code ${rc}, restarting in ${backoff} seconds`); await sleepSeconds(backoff); backoff = Math.min(backoff * 2, MAX_BACKOFF); } } function shutdown(signal: string): void { stopping = true; console.error(`Received ${signal}, shutting down`); if (child) { try { child.kill('SIGTERM'); } catch { // ignore } } // give processes a moment to terminate gracefully // eslint-disable-next-line unicorn/no-process-exit,n/no-process-exit setTimeout((): never => process.exit(0), 500); } process.on('SIGINT', (): void => shutdown('SIGINT')); process.on('SIGTERM', (): void => shutdown('SIGTERM')); // eslint-disable-next-line unicorn/prefer-top-level-await main().catch((error): never => { console.error('Unhandled error in persist-port-forward:', error); // eslint-disable-next-line unicorn/no-process-exit,n/no-process-exit process.exit(1); });