UNPKG

@hashgraph/solo

Version:

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

912 lines (911 loc) 59.9 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; import { Listr } from 'listr2'; import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'; import { select as selectPrompt } from '@inquirer/prompts'; import { SoloError } from '../core/errors/solo-error.js'; import { BaseCommand } from './base.js'; import { Flags as flags } from './flags.js'; import * as constants from '../core/constants.js'; import chalk from 'chalk'; import { NamespaceName } from '../types/namespace/namespace-name.js'; import { inject, injectable } from 'tsyringe-neo'; import { InjectTokens } from '../core/dependency-injection/inject-tokens.js'; import { Templates } from '../core/templates.js'; import { resolveNamespaceFromDeployment } from '../core/resolvers.js'; import { patchInject } from '../core/dependency-injection/container-helper.js'; import { DeploymentStates } from '../core/config/remote/enumerations/deployment-states.js'; import { LedgerPhase } from '../data/schema/model/remote/ledger-phase.js'; import { StringFacade } from '../business/runtime-state/facade/string-facade.js'; import { remoteConfigsToDeploymentsTable } from '../core/helpers.js'; import { MessageLevel } from '../core/logging/message-level.js'; import { PodReference } from '../integration/kube/resources/pod/pod-reference.js'; import * as version from '../../version.js'; import find from 'find-process'; import { SoloErrors } from '../core/errors/solo-errors.js'; import yaml from 'yaml'; import { PathEx } from '../business/utils/path-ex.js'; import fs from 'node:fs/promises'; let DeploymentCommand = class DeploymentCommand extends BaseCommand { tasks; constructor(tasks) { super(); this.tasks = tasks; this.tasks = patchInject(tasks, InjectTokens.ClusterCommandTasks, this.constructor.name); } static CREATE_FLAGS_LIST = { required: [flags.namespace, flags.deployment], optional: [flags.quiet, flags.realm, flags.shard], }; static DESTROY_FLAGS_LIST = { required: [flags.deployment], optional: [flags.quiet], }; static ADD_CLUSTER_FLAGS_LIST = { required: [flags.deployment, flags.clusterRef], optional: [ flags.quiet, flags.enableCertManager, flags.numberOfConsensusNodes, flags.dnsBaseDomain, flags.dnsConsensusNodePattern, ], }; static LIST_DEPLOYMENTS_FLAGS_LIST = { required: [], optional: [flags.clusterRef, flags.quiet], }; static SHOW_STATUS_FLAGS_LIST = { required: [], optional: [flags.deployment, flags.clusterRef, flags.quiet], }; static REFRESH_FLAGS_LIST = { required: [flags.deployment], optional: [flags.quiet], }; static PORTS_FLAGS_LIST = { required: [flags.deployment], optional: [flags.clusterRef, flags.quiet, flags.output, flags.cacheDir], }; /** * Create new deployment inside the local config */ async create(argv) { const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); this.configManager.update(argv); await this.configManager.executePrompt(task, [flags.namespace, flags.deployment]); context_.config = { quiet: this.configManager.getFlag(flags.quiet), namespace: this.configManager.getFlag(flags.namespace), deployment: this.configManager.getFlag(flags.deployment), realm: this.configManager.getFlag(flags.realm) || flags.realm.definition.defaultValue, shard: this.configManager.getFlag(flags.shard) || flags.shard.definition.defaultValue, }; if (this.localConfig.configuration.deployments && this.localConfig.configuration.deployments.some((d) => d.name === context_.config.deployment)) { const deploymentName = context_.config.deployment; const existingDeployment = this.localConfig.configuration.deploymentByName(deploymentName); const deploymentNamespace = NamespaceName.of(existingDeployment.namespace); const clusterReferences = existingDeployment.clusters; let deploymentExistsInCluster = false; for (const clusterReferenceFacade of clusterReferences) { const clusterReference = clusterReferenceFacade.toString(); const clusterContext = this.localConfig.configuration.clusterRefs .get(clusterReference) ?.toString(); if (clusterContext) { try { const k8 = this.k8Factory.getK8(clusterContext); const namespaceExists = await k8.namespaces().has(deploymentNamespace); if (namespaceExists) { const remoteConfigExists = await k8 .configMaps() .exists(deploymentNamespace, constants.SOLO_REMOTE_CONFIGMAP_NAME); if (remoteConfigExists) { deploymentExistsInCluster = true; break; } } } catch (error) { this.logger.debug(`Could not connect to cluster context '${clusterContext}' for deployment '${deploymentName}': ${error instanceof Error ? error.message : String(error)}. Treating as stale.`); } } } if (deploymentExistsInCluster) { throw new SoloErrors.deployment.alreadyExists(context_.config.deployment); } // Local config is stale - deployment does not actually exist in any cluster this.logger.showUser(chalk.yellow(`\nLocal config shows deployment '${deploymentName}' exists, ` + 'but no matching resources were found in the cluster. ' + 'Cleaning up stale local config and proceeding with fresh deployment.')); this.localConfig.configuration.deployments.remove(existingDeployment); await this.localConfig.persist(); } }, }, { title: 'Add deployment to local config', task: async (context_, task) => { const { namespace, deployment, realm, shard } = context_.config; task.title = `Adding deployment: ${deployment} with namespace: ${namespace.name} to local config`; if (this.localConfig.configuration.deployments.some((d) => d.name === deployment)) { throw new SoloError(`Deployment ${deployment} is already added to local config`); } const actualDeployment = this.localConfig.configuration.deployments.addNew(); actualDeployment.name = deployment; actualDeployment.namespace = namespace.name; actualDeployment.realm = realm; actualDeployment.shard = shard; await this.localConfig.persist(); }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'deployment config create'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloErrors.deployment.createFailed(error); } } return true; } /** * Delete a deployment from the local config */ async delete(argv) { const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); try { await this.remoteConfig.loadAndValidate(argv); } catch { // Guard } this.configManager.update(argv); await this.configManager.executePrompt(task, [flags.deployment]); context_.config = { quiet: this.configManager.getFlag(flags.quiet), deployment: this.configManager.getFlag(flags.deployment), }; const deployment = context_.config.deployment; if (!this.localConfig.configuration.deployments?.some((d) => d.name === deployment)) { context_.config.skipRemoteDelete = true; } }, }, { title: 'Check for existing remote resources', task: async ({ config: { deployment } }) => { const clusterReferences = this.localConfig.configuration.deploymentByName(deployment).clusters; for (const clusterReferenceFacade of clusterReferences) { const clusterReference = clusterReferenceFacade.toString(); const namespace = NamespaceName.of(this.localConfig.configuration.deploymentByName(deployment).namespace); const context = this.localConfig.configuration.clusterRefs .get(clusterReference) ?.toString(); const remoteConfigExists = await this.remoteConfig .remoteConfigExists(namespace, context) .catch(() => false); let existingConfigMaps = []; try { existingConfigMaps = await this.k8Factory .getK8(context) .configMaps() .list(namespace, ['app.kubernetes.io/managed-by=Helm']); } catch { // Guard } if (remoteConfigExists || existingConfigMaps.length > 0) { throw new SoloError(`Deployment ${deployment} has remote resources in cluster: ${clusterReference}`); } } }, skip: ({ config: { skipRemoteDelete } }) => skipRemoteDelete === true, }, { title: 'Remove deployment from local config', task: async ({ config: { deployment } }) => { try { const actualDeployment = this.localConfig.configuration.deploymentByName(deployment); if (actualDeployment) { this.localConfig.configuration.deployments.remove(actualDeployment); } await this.localConfig.persist(); } catch { // Deployment might not exist in local config, ignore error and continue with cleanup of other deployments if needed } }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'deployment config delete'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError('Error deleting deployment', error); } } return true; } /** * Add new cluster for specified deployment, and create or edit the remote config */ async addCluster(argv) { const tasks = this.taskList.newTaskList([ this.initializeClusterAddConfig(argv), this.verifyClusterAddArgs(), this.checkNetworkState(), this.testClusterConnection(), this.verifyClusterAddPrerequisites(), this.checkForExistingDeployments(), this.addClusterRefToDeployments(), this.createOrEditRemoteConfigForNewDeployment(argv), ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'deployment cluster attach'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError('Error adding cluster to deployment', error); } } return true; } async list(argv) { const tasks = new Listr([ { title: 'Initialize', task: async (context_) => { await this.localConfig.load(); this.configManager.update(argv); const clusterName = this.configManager.getFlag(flags.clusterRef); // Note: cluster-ref is now optional. If not provided, we list local deployments. // We no longer prompt for cluster-ref to allow listing all deployments without requiring cluster access. context_.config = { clusterName, }; }, }, { title: 'List deployments from local configuration', task: async (context_) => { const clusterName = context_.config.clusterName; const deploymentRows = []; const deployments = []; if (this.localConfig.configuration.deployments) { for (const deployment of this.localConfig.configuration.deployments) { deployments.push(deployment); } } for (const deployment of deployments) { const deploymentNamespace = NamespaceName.of(deployment.namespace); const clusterReferences = deployment.clusters; if (clusterReferences.length === 0) { if (!clusterName) { deploymentRows.push(`${deployment.name} | namespace=${deploymentNamespace.name} | cluster-ref=<none> | context=<none> | status=disconnected`); } continue; } for (const clusterReferenceFacade of clusterReferences) { const clusterReference = clusterReferenceFacade.toString(); if (clusterName && clusterReference !== clusterName) { continue; } const clusterContext = this.localConfig.configuration.clusterRefs .get(clusterReference) ?.toString(); let status = 'disconnected'; if (clusterContext) { const k8 = this.k8Factory.getK8(clusterContext); try { await k8.namespaces().list(); const remoteConfigExists = await k8 .configMaps() .exists(deploymentNamespace, constants.SOLO_REMOTE_CONFIGMAP_NAME); status = remoteConfigExists ? 'connected' : 'not-found'; } catch { status = 'disconnected'; } } deploymentRows.push(`${deployment.name} | namespace=${deploymentNamespace.name} | cluster-ref=${clusterReference} | context=${clusterContext ?? '<none>'} | status=${status}`); } } const title = clusterName ? `Local deployments for cluster-ref: ${chalk.cyan(clusterName)}` : 'Local deployments'; this.logger.showList(title, deploymentRows); }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT); try { await tasks.run(); } catch (error) { throw new SoloError('Error listing deployments', error); } return true; } async close() { } // no-op async ports(argv) { const tasks = new Listr([ { title: 'Initialize', task: async (context_) => { await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); this.configManager.update(argv); const deployment = this.configManager.getFlag(flags.deployment); const deploymentConfig = this.localConfig.configuration.deploymentByName(deployment); if (!deploymentConfig) { throw new SoloError(`Deployment ${deployment} not found in local config`); } let output = 'wide'; const rawOutput = this.configManager.getFlag(flags.output); switch (rawOutput) { case '': { output = 'wide'; break; } case 'json': case 'yaml': case 'wide': { output = rawOutput; break; } default: { throw new SoloError(`Invalid output format: ${rawOutput}. Allowed values: json, yaml, wide`); } } context_.config = { clusterReference: this.getClusterReference(), quiet: this.configManager.getFlag(flags.quiet), deployment, deploymentConfig, namespace: NamespaceName.of(deploymentConfig.namespace), output, cacheDirectory: this.configManager.getFlag(flags.cacheDir), }; }, }, { title: 'List deployment port-forwards', task: async ({ config }, task) => { const { deployment, namespace, clusterReference, output } = config; const state = this.remoteConfig.configuration.state; const collectEntries = (components) => { const entries = []; for (const component of components) { const portForwardConfigs = component.metadata?.portForwardConfigs || []; for (const portForwardConfig of portForwardConfigs) { entries.push({ componentId: component.metadata.id, localPort: portForwardConfig.localPort, podPort: portForwardConfig.podPort, }); } } return entries; }; const report = { deployment, clusterReference, namespace: namespace.name, services: { consensusNodeGrpc: collectEntries(state.haProxies || []), mirrorNodeRest: collectEntries(state.mirrorNodes || []), jsonRpcRelay: collectEntries(state.relayNodes || []), explorer: collectEntries(state.explorers || []), blockNode: collectEntries(state.blockNodes || []), }, }; const targetDirectory = PathEx.join(config.cacheDirectory, 'output'); await fs.mkdir(targetDirectory, { recursive: true }); if (output === 'json') { const targetFile = PathEx.join(targetDirectory, 'forwarded-ports.json'); const jsonData = JSON.stringify(report, undefined, 2); await fs.writeFile(targetFile, jsonData, 'utf8'); this.logger.showUser(`Ports data file written to: ${targetFile}`); this.logger.showUser(jsonData); } else if (output === 'yaml') { const targetFile = PathEx.join(targetDirectory, 'forwarded-ports.yaml'); const yamlData = yaml.stringify(report); await fs.writeFile(targetFile, yamlData, 'utf8'); this.logger.showUser(`Ports data file written to: ${targetFile}`); this.logger.showUser(yamlData); } else { this.logger.showUser(chalk.cyan(`\n=== Port-forwards for deployment: ${deployment} ===`)); this.logger.showUser(`Cluster: ${clusterReference}`); this.logger.showUser(`Namespace: ${namespace.name}`); const serviceGroups = [ { title: 'Consensus node gRPC', entries: report.services.consensusNodeGrpc }, { title: 'Mirror node REST', entries: report.services.mirrorNodeRest }, { title: 'JSON-RPC relay', entries: report.services.jsonRpcRelay }, { title: 'Explorer', entries: report.services.explorer }, { title: 'Block node', entries: report.services.blockNode }, ]; let foundAnyPortForwards = false; for (const { title, entries } of serviceGroups) { if (entries.length === 0) { continue; } foundAnyPortForwards = true; this.logger.showList(title, entries.map((entry) => `component ${entry.componentId}: localhost:${entry.localPort} -> pod:${entry.podPort}`)); } if (!foundAnyPortForwards) { this.logger.showUser(chalk.yellow('No port-forwards configured in remote config')); } } task.title = `Listed port-forwards for deployment ${deployment}`; }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT); try { await tasks.run(); } catch (error) { throw new SoloError('Error listing deployment ports', error); } return true; } /** * Initializes and populates the config and context for 'deployment cluster attach' */ initializeClusterAddConfig(argv) { return { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); this.configManager.update(argv); await this.configManager.executePrompt(task, [flags.deployment, flags.clusterRef]); context_.config = { quiet: this.configManager.getFlag(flags.quiet), namespace: await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task), deployment: this.configManager.getFlag(flags.deployment), clusterRef: this.configManager.getFlag(flags.clusterRef), enableCertManager: this.configManager.getFlag(flags.enableCertManager), numberOfConsensusNodes: this.configManager.getFlag(flags.numberOfConsensusNodes), dnsBaseDomain: this.configManager.getFlag(flags.dnsBaseDomain), dnsConsensusNodePattern: this.configManager.getFlag(flags.dnsConsensusNodePattern), existingNodesCount: 0, nodeAliases: [], context: '', }; }, }; } /** * Validates: * - cluster ref is present in the local config's cluster-ref => context mapping * - the deployment is created * - the cluster-ref is not already added to the deployment */ verifyClusterAddArgs() { return { title: 'Verify args', task: async (context_) => { const { clusterRef, deployment } = context_.config; if (!this.localConfig.configuration.clusterRefs.get(clusterRef)) { throw new SoloError(`Cluster ref ${clusterRef} not found in local config`); } context_.config.context = this.localConfig.configuration.clusterRefs.get(clusterRef)?.toString(); if (!this.localConfig.configuration.deploymentByName(deployment)) { throw new SoloError(`Deployment ${deployment} not found in local config`); } if (this.localConfig.configuration.deploymentByName(deployment).clusters.includes(new StringFacade(clusterRef))) { throw new SoloError(`Cluster ref ${clusterRef} is already added for deployment`); } }, }; } /** * Checks the ledger phase: * - if remote config is found check's the ledgerPhase field to see if it's pre or post genesis. * - pre genesis: * - prompts user if needed. * - generates node aliases based on '--number-of-consensus-nodes' * - post genesis: * - throws if '--number-of-consensus-nodes' is passed * - if remote config is not found: * - prompts user if needed. * - generates node aliases based on '--number-of-consensus-nodes'. */ checkNetworkState() { return { title: 'check ledger phase', task: async (context_, task) => { const { deployment, numberOfConsensusNodes, quiet, namespace } = context_.config; const existingClusterReferences = this.localConfig.configuration.deploymentByName(deployment).clusters; // if there is no remote config don't validate deployment ledger phase if (existingClusterReferences.length === 0) { context_.config.ledgerPhase = LedgerPhase.UNINITIALIZED; // if the user can't be prompted for '--num-consensus-nodes' fail if (!numberOfConsensusNodes && quiet) { throw new SoloError(`--${flags.numberOfConsensusNodes} must be specified ${DeploymentStates.PRE_GENESIS}`); } // prompt the user for the '--num-consensus-nodes' else if (!numberOfConsensusNodes) { await this.configManager.executePrompt(task, [flags.numberOfConsensusNodes]); context_.config.numberOfConsensusNodes = this.configManager.getFlag(flags.numberOfConsensusNodes); } context_.config.nodeAliases = Templates.renderNodeAliasesFromCount(context_.config.numberOfConsensusNodes, 0); return; } const existingClusterContext = this.localConfig.configuration.clusterRefs .get(existingClusterReferences.get(0)?.toString()) ?.toString(); context_.config.existingClusterContext = existingClusterContext; await this.remoteConfig.populateFromExisting(namespace, existingClusterContext); const ledgerPhase = this.remoteConfig.configuration.state.ledgerPhase; context_.config.ledgerPhase = ledgerPhase; const existingNodesCount = Object.keys(this.remoteConfig.configuration.state.consensusNodes).length; context_.config.nodeAliases = Templates.renderNodeAliasesFromCount(numberOfConsensusNodes, existingNodesCount); // If ledgerPhase is pre-genesis and user can't be prompted for the '--num-consensus-nodes' fail if (ledgerPhase === LedgerPhase.UNINITIALIZED && !numberOfConsensusNodes && quiet) { throw new SoloError(`--${flags.numberOfConsensusNodes} must be specified ${LedgerPhase.UNINITIALIZED}`); } // If ledgerPhase is pre-genesis prompt the user for the '--num-consensus-nodes' else if (ledgerPhase === LedgerPhase.UNINITIALIZED && !numberOfConsensusNodes) { await this.configManager.executePrompt(task, [flags.numberOfConsensusNodes]); context_.config.numberOfConsensusNodes = this.configManager.getFlag(flags.numberOfConsensusNodes); context_.config.nodeAliases = Templates.renderNodeAliasesFromCount(context_.config.numberOfConsensusNodes, existingNodesCount); } // if the ledgerPhase is post-genesis and '--num-consensus-nodes' is specified throw else if (ledgerPhase === LedgerPhase.INITIALIZED && numberOfConsensusNodes) { throw new SoloError(`--${flags.numberOfConsensusNodes.name}=${numberOfConsensusNodes} shouldn't be specified ${ledgerPhase}`); } }, }; } /** * Tries to connect with the cluster using the context from the local config */ testClusterConnection() { return { title: 'Test cluster reference connection', task: async (context_, task) => { const { clusterRef, context } = context_.config; task.title += `: ${clusterRef}, context: ${context}`; const isConnected = await this.k8Factory .getK8(context) .namespaces() .list() .then(() => true) .catch(() => false); if (!isConnected) { throw new SoloError(`Connection failed for cluster ${clusterRef} with context: ${context}`); } }, }; } verifyClusterAddPrerequisites() { return { title: 'Verify prerequisites', task: async () => { // TODO: Verifies Kubernetes cluster & namespace-level prerequisites (e.g., cert-manager, HAProxy, etc.) }, }; } checkForExistingDeployments() { return { title: 'Check for other deployments', task: async () => { await this.showExistingDeploymentsInCluster(); }, }; } /** * Adds the new cluster-ref for the deployment in local config */ addClusterRefToDeployments() { return { title: 'add cluster-ref in local config deployments', task: async ({ config: { clusterRef, deployment } }, task) => { task.title = `add cluster-ref: ${clusterRef} for deployment: ${deployment} in local config`; const existsInLocalConfig = this.localConfig.configuration .deploymentByName(deployment) .clusters.some((cluster) => cluster.toString() === clusterRef); if (existsInLocalConfig) { this.logger.showUser(`Cluster-ref: ${clusterRef} already exists for deployment: ${deployment} in local config`); } else { this.logger.showUser(`Adding cluster-ref: ${clusterRef} for deployment: ${deployment} in local config`); this.localConfig.configuration.deploymentByName(deployment).clusters.add(new StringFacade(clusterRef)); } await this.localConfig.persist(); }, }; } /** * - if remote config not found, create new remote config for the deployment. * - if remote config is found, add the new data for the deployment. */ createOrEditRemoteConfigForNewDeployment(argv) { return { title: 'create remote config for deployment', task: async (context_, task) => { const { deployment, clusterRef, context, ledgerPhase, nodeAliases, namespace, existingClusterContext, dnsBaseDomain, dnsConsensusNodePattern, } = context_.config; argv[flags.nodeAliasesUnparsed.name] = nodeAliases.join(','); task.title += `: ${deployment} in cluster reference: ${clusterRef}`; if (!(await this.k8Factory.getK8(context).namespaces().has(namespace))) { await this.k8Factory.getK8(context).namespaces().create(namespace); } if (await this.k8Factory.getK8(context).configMaps().exists(namespace, constants.SOLO_REMOTE_CONFIGMAP_NAME)) { this.logger.showUser(`Remote config already exists for deployment: ${deployment} in cluster: ${clusterRef}`); return; } await (existingClusterContext ? this.remoteConfig.createFromExisting(namespace, clusterRef, deployment, this.componentFactory, dnsBaseDomain, dnsConsensusNodePattern, existingClusterContext, argv, nodeAliases) : this.remoteConfig.create(argv, ledgerPhase, nodeAliases, namespace, deployment, clusterRef, context, dnsBaseDomain, dnsConsensusNodePattern)); }, }; } /** Show list of existing deployments in the cluster */ async showExistingDeploymentsInCluster() { const existingRemoteConfigs = await this.k8Factory .default() .configMaps() .listForAllNamespaces(Templates.renderConfigMapRemoteConfigLabels()); if (existingRemoteConfigs.length > 0) { const messageGroupName = 'existing-deployments'; this.logger.addMessageGroup(messageGroupName, '⚠️ Warning: Existing solo deployment detected in cluster.'); const existingDeploymentsRows = remoteConfigsToDeploymentsTable(existingRemoteConfigs); for (const row of existingDeploymentsRows) { this.logger.addMessageGroupMessage(messageGroupName, row); } this.logger.showMessageGroup(messageGroupName, MessageLevel.WARN); } } /** * Refresh port-forward processes for all components in the deployment */ async refresh(argv) { const tasks = new Listr([ { title: 'Initialize', task: async (context_) => { await this.localConfig.load(); this.configManager.update(argv); context_.config = { quiet: this.configManager.getFlag(flags.quiet), deployment: this.configManager.getFlag(flags.deployment), }; // Get namespace from deployment const deployment = this.localConfig.configuration.deploymentByName(context_.config.deployment); if (!deployment) { throw new SoloError(`Deployment ${context_.config.deployment} not found in local config`); } context_.namespace = NamespaceName.of(deployment.namespace); }, }, { title: 'Load remote configuration', task: async (context_, task) => { if (!context_.namespace) { throw new SoloError('Namespace not set'); } // Load remote config from a selected cluster in the deployment const deployment = this.localConfig.configuration.deploymentByName(context_.config.deployment); const clusters = deployment.clusters; if (clusters.length === 0) { throw new SoloError(`No clusters found for deployment ${context_.config.deployment}`); } const clusterReferences = []; for (let index = 0; index < clusters.length; index++) { const clusterReferenceFacade = clusters.get(index); if (clusterReferenceFacade) { clusterReferences.push(clusterReferenceFacade.toString()); } } if (clusterReferences.length === 0) { throw new SoloError(`Failed to get cluster reference for deployment ${context_.config.deployment}`); } let clusterReference = clusterReferences[0]; if (clusterReferences.length > 1) { clusterReference = (await task.prompt(ListrInquirerPromptAdapter).run(selectPrompt, { message: `Multiple clusters found for deployment '${context_.config.deployment}'. Select cluster reference:`, choices: clusterReferences.map((reference) => ({ name: `${reference} (${this.localConfig.configuration.clusterRefs.get(reference)?.toString() ?? 'no-context'})`, value: reference, })), })); } const contextValue = this.localConfig.configuration.clusterRefs.get(clusterReference); if (!contextValue) { throw new SoloError(`Context not found for cluster reference ${clusterReference}`); } const context = contextValue.toString(); context_.clusterReference = clusterReference; context_.context = context; await this.remoteConfig.load(context_.namespace, context); }, }, { title: 'Refresh port-forwards for all components', task: async (_context_, task) => { const componentsToCheck = [ { type: 'ConsensusNode', components: this.remoteConfig.configuration.state.consensusNodes || [] }, { type: 'HaProxy', components: this.remoteConfig.configuration.state.haProxies || [] }, { type: 'BlockNode', components: this.remoteConfig.configuration.state.blockNodes || [] }, { type: 'MirrorNode', components: this.remoteConfig.configuration.state.mirrorNodes || [] }, { type: 'RelayNode', components: this.remoteConfig.configuration.state.relayNodes || [] }, { type: 'Explorer', components: this.remoteConfig.configuration.state.explorers || [] }, ]; let restoredCount = 0; let totalChecked = 0; let alreadyRunningCount = 0; const portForwardDetails = []; this.logger.showUser(chalk.cyan('\n=== Port-Forward Status Check ===\n')); for (const { type, components } of componentsToCheck) { for (const component of components) { if (!component.metadata?.portForwardConfigs || component.metadata.portForwardConfigs.length === 0) { continue; } const { cluster: clusterReference, namespace } = component.metadata; const context = this.localConfig.configuration.clusterRefs .get(clusterReference) ?.toString(); const k8Client = this.k8Factory.getK8(context); for (const portForwardConfig of component.metadata.portForwardConfigs) { totalChecked++; const { localPort, podPort } = portForwardConfig; const componentLabel = `${type} ${component.metadata.id}`; // Check if port-forward is running const isRunning = await this.isPortForwardRunning(localPort); if (isRunning) { alreadyRunningCount++; const detail = `✓ ${componentLabel}: localhost:${localPort} -> pod:${podPort} [Running]`; portForwardDetails.push(detail); this.logger.showUser(chalk.green(detail)); } else { const missingDetail = `⚠ ${componentLabel}: localhost:${localPort} -> pod:${podPort} [Missing]`; portForwardDetails.push(missingDetail); this.logger.showUser(chalk.yellow(missingDetail)); try { // Find the pod reference for this component const namespaceName = NamespaceName.of(namespace); const podName = await this.getPodNameForComponent(component, type, k8Client, namespaceName); if (podName) { // Re-enable port forward const podReference = PodReference.of(namespaceName, podName); // portForward parameters: // - localPort: the port to forward to on localhost // - podPort: the port on the pod to forward from // - reuse: true = reuse the configured port number // - persist: true = persistent port-forward (will restart on failure) await k8Client.pods().readByReference(podReference).portForward(localPort, podPort, true, true); const restoredDetail = ` ↳ Restored port forward for ${componentLabel}`; this.logger.showUser(chalk.green(restoredDetail)); restoredCount++; } else { const errorDetail = ` ↳ Could not find pod for ${componentLabel}`; this.logger.showUser(chalk.red(errorDetail)); } } catch (error) { const errorDetail = ` ↳ Failed to restore: ${error.message}`; this.logger.showUser(chalk.red(errorDetail)); } } } } } this.logger.showUser(chalk.cyan('\n=== Summary ===')); this.logger.showUser(`Total port-forwards configured: ${totalChecked}`); this.logger.showUser(chalk.green(`Already running: ${alreadyRunningCount}`)); if (restoredCount > 0) { this.logger.showUser(chalk.green(`Successfully restored: ${restoredCount}`)); } if (totalChecked === 0) { this.logger.showUser(chalk.yellow('No port-forwards configured in this deployment')); } else if (alreadyRunningCount === totalChecked) { this.logger.showUser(chalk.green('✓ All port-forwards are running correctly')); } task.title = `Checked ${totalChecked} port-forward(s), restored ${restoredCount}`; }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT); try { await tasks.run(); } catch (error) { throw new SoloError('Error refreshing port-forwards', error); } return true; } /** * Check if a port-forward process is running on the specified port */ async isPortForwardRunning(port) { // Validate port before process matching. if (!Number.isInteger(port) || port <= 0 || port > 65_535) { throw new SoloError(`Invalid port number: ${port}`); } try { const foundProcess = await find('name', 'port-forward', { skipSelf: true }); return foundProcess.some((process) => { const command = (process.cmd ?? '').toLowerCase(); return command.includes('port-forward') && command.includes(`${port}:`); }); } catch { return false; } } /** * Display the full deployment status including component info, versions, and port-forward status. * If no deployment is specified, iterates over all local deployments. */ async showDeploymentStatus(argv) { const tasks = new Listr([ { title: 'Initialize', task: async (context_) => { await this.localConfig.load(); this.configManager.update(argv); context_.config = { quiet: this.configManager.getFlag(flags.quiet), deployment: this.configManager.getFlag(flags.deployment), }; if (context_.config.deployment) { const deployment = this.localConfig.configuration.deploymentByName(context_.config.deployment); if (!deployment) { throw new SoloError(`Deployment ${context_.config.deployment} not found in local config`); } context_.deployments = [deployment]; } else { const allDeployments = []; if (this.localConfig.configuration.deployments) { for (const d of this.localConfig.configuration.deployments) { allDeployments.push(d); } } if (allDeployments.length === 0) { throw new SoloError('No deployments found in local config'); } context_.deployments = allDeployments; } }, }, { title: 'Display deployment status', task: async (context_, task) => { // Show versions once at the top this.logger.showUser(chalk.cyan('\nVersions:')); this.logger.showUser(` Solo Chart Version: ${chalk.bold(version.SOLO_CHART_VERSION)}`); this.logger.showUser(` Consensus Node Version: ${chalk.bold(version.HEDERA_PLATFORM_VERSION)}`); this.logger.showUser(` Mirror Node Version: ${chalk.bold(version.MIRROR_NODE_VERSION)}`); this.logger.showUser(` Explorer Version: ${chalk.bold(version.EXPLORER_VERSION)}`); this.logger.showUser(` JSON RPC Relay Version: ${chalk.bold(version.HEDERA_JSON_RPC_RELAY_VERSION)}`); this.logger.showUser(` Block Node Version: ${chalk.bold(version.BLOCK_NODE_VERSION)}`); let grandTotalChecked = 0; let grandRunning = 0; let grandNotRunning = 0;