@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
1,274 lines (1,091 loc) • 59.1 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
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 {type ClusterCommandTasks} from './cluster/tasks.js';
import {
type ClusterReferenceName,
type Context,
type DeploymentName,
type Optional,
type PortForwardConfig,
type Realm,
type Shard,
type SoloListr,
type SoloListrTask,
} from '../types/index.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {inject, injectable} from 'tsyringe-neo';
import {InjectTokens} from '../core/dependency-injection/inject-tokens.js';
import {type ArgvStruct, type NodeAliases} from '../types/aliases.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 {Deployment} from '../business/runtime-state/config/local/deployment.js';
import {CommandFlags} from '../types/flag-types.js';
import {type ConfigMap} from '../integration/kube/resources/config-map/config-map.js';
import {type FacadeArray} from '../business/runtime-state/collection/facade-array.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 {PodName} from '../integration/kube/resources/pod/pod-name.js';
import {Pod} from '../integration/kube/resources/pod/pod.js';
import {type K8} from '../integration/kube/k8.js';
import {type BaseStateSchema} from '../data/schema/model/remote/state/base-state-schema.js';
import * as version from '../../version.js';
import find from 'find-process';
import type ProcessInfo from 'find-process';
import {SoloErrors} from '../core/errors/solo-errors.js';
import {DeploymentStateSchema} from '../data/schema/model/remote/deployment-state-schema.js';
import yaml from 'yaml';
import {PathEx} from '../business/utils/path-ex.js';
import fs from 'node:fs/promises';
interface DeploymentAddClusterConfig {
quiet: boolean;
context: string;
namespace: NamespaceName;
deployment: DeploymentName;
clusterRef: ClusterReferenceName;
enableCertManager: boolean;
numberOfConsensusNodes: number;
dnsBaseDomain: string;
dnsConsensusNodePattern: string;
ledgerPhase?: LedgerPhase;
nodeAliases: NodeAliases;
existingNodesCount: number;
existingClusterContext?: string;
}
export interface DeploymentAddClusterContext {
config: DeploymentAddClusterConfig;
}
@injectable()
export class DeploymentCommand extends BaseCommand {
public constructor(@inject(InjectTokens.ClusterCommandTasks) private readonly tasks: ClusterCommandTasks) {
super();
this.tasks = patchInject(tasks, InjectTokens.ClusterCommandTasks, this.constructor.name);
}
public static CREATE_FLAGS_LIST: CommandFlags = {
required: [flags.namespace, flags.deployment],
optional: [flags.quiet, flags.realm, flags.shard],
};
public static DESTROY_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [flags.quiet],
};
public static ADD_CLUSTER_FLAGS_LIST: CommandFlags = {
required: [flags.deployment, flags.clusterRef],
optional: [
flags.quiet,
flags.enableCertManager,
flags.numberOfConsensusNodes,
flags.dnsBaseDomain,
flags.dnsConsensusNodePattern,
],
};
public static LIST_DEPLOYMENTS_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.clusterRef, flags.quiet],
};
public static SHOW_STATUS_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.deployment, flags.clusterRef, flags.quiet],
};
public static REFRESH_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [flags.quiet],
};
public static PORTS_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [flags.clusterRef, flags.quiet, flags.output, flags.cacheDir],
};
/**
* Create new deployment inside the local config
*/
public async create(argv: ArgvStruct): Promise<boolean> {
interface Config {
quiet: boolean;
namespace: NamespaceName;
deployment: DeploymentName;
realm: Realm;
shard: Shard;
}
interface Context {
config: Config;
}
const tasks: ReturnType<typeof this.taskList.newTaskList> = this.taskList.newTaskList(
[
{
title: 'Initialize',
task: async (context_: Context, task): Promise<void> => {
await this.localConfig.load();
this.configManager.update(argv);
await this.configManager.executePrompt(task, [flags.namespace, flags.deployment]);
context_.config = {
quiet: this.configManager.getFlag<boolean>(flags.quiet),
namespace: this.configManager.getFlag<NamespaceName>(flags.namespace),
deployment: this.configManager.getFlag<DeploymentName>(flags.deployment),
realm: this.configManager.getFlag<Realm>(flags.realm) || flags.realm.definition.defaultValue,
shard: this.configManager.getFlag<Shard>(flags.shard) || flags.shard.definition.defaultValue,
} as Config;
if (
this.localConfig.configuration.deployments &&
this.localConfig.configuration.deployments.some(
(d: Deployment): boolean => d.name === context_.config.deployment,
)
) {
const deploymentName: DeploymentName = context_.config.deployment;
const existingDeployment: Deployment = this.localConfig.configuration.deploymentByName(deploymentName);
const deploymentNamespace: NamespaceName = NamespaceName.of(existingDeployment.namespace);
const clusterReferences: FacadeArray<StringFacade, string> = existingDeployment.clusters;
let deploymentExistsInCluster: boolean = false;
for (const clusterReferenceFacade of clusterReferences) {
const clusterReference: string = clusterReferenceFacade.toString();
const clusterContext: Optional<string> = this.localConfig.configuration.clusterRefs
.get(clusterReference)
?.toString();
if (clusterContext) {
try {
const k8: K8 = this.k8Factory.getK8(clusterContext);
const namespaceExists: boolean = await k8.namespaces().has(deploymentNamespace);
if (namespaceExists) {
const remoteConfigExists: boolean = await k8
.configMaps()
.exists(deploymentNamespace, constants.SOLO_REMOTE_CONFIGMAP_NAME);
if (remoteConfigExists) {
deploymentExistsInCluster = true;
break;
}
}
} catch (error: unknown) {
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_: Context, task): Promise<void> => {
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: Deployment): boolean => d.name === deployment)) {
throw new SoloError(`Deployment ${deployment} is already added to local config`);
}
const actualDeployment: Deployment = 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
*/
public async delete(argv: ArgvStruct): Promise<boolean> {
interface Config {
quiet: boolean;
namespace: NamespaceName;
deployment: DeploymentName;
skipRemoteDelete: boolean;
}
interface Context {
config: Config;
}
const tasks: ReturnType<typeof this.taskList.newTaskList> = this.taskList.newTaskList(
[
{
title: 'Initialize',
task: async (context_: Context, task): Promise<void> => {
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),
} as Config;
const deployment: DeploymentName = context_.config.deployment;
if (!this.localConfig.configuration.deployments?.some((d): boolean => d.name === deployment)) {
context_.config.skipRemoteDelete = true;
}
},
},
{
title: 'Check for existing remote resources',
task: async ({config: {deployment}}): Promise<void> => {
const clusterReferences: FacadeArray<StringFacade, string> =
this.localConfig.configuration.deploymentByName(deployment).clusters;
for (const clusterReferenceFacade of clusterReferences) {
const clusterReference: ClusterReferenceName = clusterReferenceFacade.toString();
const namespace: NamespaceName = NamespaceName.of(
this.localConfig.configuration.deploymentByName(deployment).namespace,
);
const context: Optional<string> = this.localConfig.configuration.clusterRefs
.get(clusterReference)
?.toString();
const remoteConfigExists: boolean = await this.remoteConfig
.remoteConfigExists(namespace, context)
.catch((): boolean => false);
let existingConfigMaps: ConfigMap[] = [];
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}}): boolean => skipRemoteDelete === true,
},
{
title: 'Remove deployment from local config',
task: async ({config: {deployment}}): Promise<void> => {
try {
const actualDeployment: Deployment = 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: Error | unknown) {
throw new SoloError('Error deleting deployment', error);
}
}
return true;
}
/**
* Add new cluster for specified deployment, and create or edit the remote config
*/
public async addCluster(argv: ArgvStruct): Promise<boolean> {
const tasks: ReturnType<typeof this.taskList.newTaskList> = 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: Error | unknown) {
throw new SoloError('Error adding cluster to deployment', error);
}
}
return true;
}
public async list(argv: ArgvStruct): Promise<boolean> {
interface Config {
clusterName?: ClusterReferenceName;
}
interface Context {
config: Config;
}
const tasks: SoloListr<Context> = new Listr(
[
{
title: 'Initialize',
task: async (context_): Promise<void> => {
await this.localConfig.load();
this.configManager.update(argv);
const clusterName: ClusterReferenceName | undefined = this.configManager.getFlag<ClusterReferenceName>(
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,
} as Config;
},
},
{
title: 'List deployments from local configuration',
task: async (context_): Promise<void> => {
const clusterName: ClusterReferenceName | undefined = context_.config.clusterName;
const deploymentRows: string[] = [];
const deployments: Deployment[] = [];
if (this.localConfig.configuration.deployments) {
for (const deployment of this.localConfig.configuration.deployments) {
deployments.push(deployment);
}
}
for (const deployment of deployments) {
const deploymentNamespace: NamespaceName = NamespaceName.of(deployment.namespace);
const clusterReferences: FacadeArray<StringFacade, string> = 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: ClusterReferenceName = clusterReferenceFacade.toString();
if (clusterName && clusterReference !== clusterName) {
continue;
}
const clusterContext: string | undefined = this.localConfig.configuration.clusterRefs
.get(clusterReference)
?.toString();
let status: 'connected' | 'disconnected' | 'not-found' = 'disconnected';
if (clusterContext) {
const k8: K8 = this.k8Factory.getK8(clusterContext);
try {
await k8.namespaces().list();
const remoteConfigExists: boolean = 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: string = 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: Error | unknown) {
throw new SoloError('Error listing deployments', error);
}
return true;
}
public async close(): Promise<void> {} // no-op
public async ports(argv: ArgvStruct): Promise<boolean> {
interface PortEntry {
componentId: number;
localPort: number;
podPort: number;
}
interface PortsReport {
deployment: DeploymentName;
clusterReference: ClusterReferenceName;
namespace: string;
services: {
consensusNodeGrpc: PortEntry[];
mirrorNodeRest: PortEntry[];
jsonRpcRelay: PortEntry[];
explorer: PortEntry[];
blockNode: PortEntry[];
};
}
interface Config {
quiet: boolean;
namespace: NamespaceName;
deployment: DeploymentName;
clusterReference: ClusterReferenceName;
deploymentConfig: Deployment;
output: 'json' | 'yaml' | 'wide';
cacheDirectory: string;
}
interface PortsContext {
config: Config;
}
const tasks: SoloListr<PortsContext> = new Listr(
[
{
title: 'Initialize',
task: async (context_): Promise<void> => {
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
const deployment: DeploymentName = this.configManager.getFlag<DeploymentName>(flags.deployment);
const deploymentConfig: Deployment = this.localConfig.configuration.deploymentByName(deployment);
if (!deploymentConfig) {
throw new SoloError(`Deployment ${deployment} not found in local config`);
}
let output: 'json' | 'yaml' | 'wide' = 'wide';
const rawOutput: string = 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<boolean>(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): Promise<void> => {
const {deployment, namespace, clusterReference, output} = config;
const state: DeploymentStateSchema = this.remoteConfig.configuration.state;
const collectEntries: (components: BaseStateSchema[]) => PortEntry[] = (
components: BaseStateSchema[],
): PortEntry[] => {
const entries: PortEntry[] = [];
for (const component of components) {
const portForwardConfigs: PortForwardConfig[] = component.metadata?.portForwardConfigs || [];
for (const portForwardConfig of portForwardConfigs) {
entries.push({
componentId: component.metadata.id,
localPort: portForwardConfig.localPort,
podPort: portForwardConfig.podPort,
});
}
}
return entries;
};
const report: PortsReport = {
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: string = PathEx.join(config.cacheDirectory, 'output');
await fs.mkdir(targetDirectory, {recursive: true});
if (output === 'json') {
const targetFile: string = PathEx.join(targetDirectory, 'forwarded-ports.json');
const jsonData: string = 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: string = PathEx.join(targetDirectory, 'forwarded-ports.yaml');
const yamlData: string = 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: string; entries: PortEntry[]}[] = [
{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: boolean = false;
for (const {title, entries} of serviceGroups) {
if (entries.length === 0) {
continue;
}
foundAnyPortForwards = true;
this.logger.showList(
title,
entries.map(
(entry): string =>
`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'
*/
public initializeClusterAddConfig(argv: ArgvStruct): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'Initialize',
task: async (context_, task): Promise<void> => {
await this.localConfig.load();
this.configManager.update(argv);
await this.configManager.executePrompt(task, [flags.deployment, flags.clusterRef]);
context_.config = {
quiet: this.configManager.getFlag<boolean>(flags.quiet),
namespace: await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task),
deployment: this.configManager.getFlag<DeploymentName>(flags.deployment),
clusterRef: this.configManager.getFlag<ClusterReferenceName>(flags.clusterRef),
enableCertManager: this.configManager.getFlag<boolean>(flags.enableCertManager),
numberOfConsensusNodes: this.configManager.getFlag<number>(flags.numberOfConsensusNodes),
dnsBaseDomain: this.configManager.getFlag(flags.dnsBaseDomain),
dnsConsensusNodePattern: this.configManager.getFlag(flags.dnsConsensusNodePattern),
existingNodesCount: 0,
nodeAliases: [] as 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
*/
public verifyClusterAddArgs(): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'Verify args',
task: async (context_): Promise<void> => {
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'.
*/
public checkNetworkState(): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'check ledger phase',
task: async (context_, task): Promise<void> => {
const {deployment, numberOfConsensusNodes, quiet, namespace} = context_.config;
const existingClusterReferences: FacadeArray<StringFacade, string> =
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<number>(flags.numberOfConsensusNodes);
}
context_.config.nodeAliases = Templates.renderNodeAliasesFromCount(context_.config.numberOfConsensusNodes, 0);
return;
}
const existingClusterContext: Context = this.localConfig.configuration.clusterRefs
.get(existingClusterReferences.get(0)?.toString())
?.toString();
context_.config.existingClusterContext = existingClusterContext;
await this.remoteConfig.populateFromExisting(namespace, existingClusterContext);
const ledgerPhase: LedgerPhase = this.remoteConfig.configuration.state.ledgerPhase;
context_.config.ledgerPhase = ledgerPhase;
const existingNodesCount: number = 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<number>(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
*/
public testClusterConnection(): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'Test cluster reference connection',
task: async (context_, task): Promise<void> => {
const {clusterRef, context} = context_.config;
task.title += `: ${clusterRef}, context: ${context}`;
const isConnected: boolean = await this.k8Factory
.getK8(context)
.namespaces()
.list()
.then((): boolean => true)
.catch((): boolean => false);
if (!isConnected) {
throw new SoloError(`Connection failed for cluster ${clusterRef} with context: ${context}`);
}
},
};
}
public verifyClusterAddPrerequisites(): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'Verify prerequisites',
task: async (): Promise<void> => {
// TODO: Verifies Kubernetes cluster & namespace-level prerequisites (e.g., cert-manager, HAProxy, etc.)
},
};
}
public checkForExistingDeployments(): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'Check for other deployments',
task: async (): Promise<void> => {
await this.showExistingDeploymentsInCluster();
},
};
}
/**
* Adds the new cluster-ref for the deployment in local config
*/
public addClusterRefToDeployments(): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'add cluster-ref in local config deployments',
task: async ({config: {clusterRef, deployment}}, task): Promise<void> => {
task.title = `add cluster-ref: ${clusterRef} for deployment: ${deployment} in local config`;
const existsInLocalConfig: boolean = this.localConfig.configuration
.deploymentByName(deployment)
.clusters.some((cluster): boolean => 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.
*/
public createOrEditRemoteConfigForNewDeployment(argv: ArgvStruct): SoloListrTask<DeploymentAddClusterContext> {
return {
title: 'create remote config for deployment',
task: async (context_, task): Promise<void> => {
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 */
private async showExistingDeploymentsInCluster(): Promise<void> {
const existingRemoteConfigs: ConfigMap[] = await this.k8Factory
.default()
.configMaps()
.listForAllNamespaces(Templates.renderConfigMapRemoteConfigLabels());
if (existingRemoteConfigs.length > 0) {
const messageGroupName: string = 'existing-deployments';
this.logger.addMessageGroup(messageGroupName, '⚠️ Warning: Existing solo deployment detected in cluster.');
const existingDeploymentsRows: string[] = 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
*/
public async refresh(argv: ArgvStruct): Promise<boolean> {
interface Config {
quiet: boolean;
deployment: DeploymentName;
}
interface RefreshContext {
config: Config;
namespace?: NamespaceName;
clusterReference?: string;
context?: string;
}
const tasks: SoloListr<RefreshContext> = new Listr(
[
{
title: 'Initialize',
task: async (context_): Promise<void> => {
await this.localConfig.load();
this.configManager.update(argv);
context_.config = {
quiet: this.configManager.getFlag<boolean>(flags.quiet),
deployment: this.configManager.getFlag<DeploymentName>(flags.deployment),
} as Config;
// Get namespace from deployment
const deployment: 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): Promise<void> => {
if (!context_.namespace) {
throw new SoloError('Namespace not set');
}
// Load remote config from a selected cluster in the deployment
const deployment: Deployment = this.localConfig.configuration.deploymentByName(context_.config.deployment);
const clusters: FacadeArray<StringFacade, string> = deployment.clusters;
if (clusters.length === 0) {
throw new SoloError(`No clusters found for deployment ${context_.config.deployment}`);
}
const clusterReferences: string[] = [];
for (let index: number = 0; index < clusters.length; index++) {
const clusterReferenceFacade: StringFacade = 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: string = 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: string; value: string} => ({
name: `${reference} (${this.localConfig.configuration.clusterRefs.get(reference)?.toString() ?? 'no-context'})`,
value: reference,
})),
})) as string;
}
const contextValue: StringFacade = this.localConfig.configuration.clusterRefs.get(clusterReference);
if (!contextValue) {
throw new SoloError(`Context not found for cluster reference ${clusterReference}`);
}
const context: string = 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): Promise<void> => {
const componentsToCheck: {type: string; components: BaseStateSchema[]}[] = [
{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: number = 0;
let totalChecked: number = 0;
let alreadyRunningCount: number = 0;
const portForwardDetails: string[] = [];
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: string | undefined = this.localConfig.configuration.clusterRefs
.get(clusterReference)
?.toString();
const k8Client: K8 = this.k8Factory.getK8(context);
for (const portForwardConfig of component.metadata.portForwardConfigs) {
totalChecked++;
const {localPort, podPort} = portForwardConfig;
const componentLabel: string = `${type} ${component.metadata.id}`;
// Check if port-forward is running
const isRunning: boolean = await this.isPortForwardRunning(localPort);
if (isRunning) {
alreadyRunningCount++;
const detail: string = `✓ ${componentLabel}: localhost:${localPort} -> pod:${podPort} [Running]`;
portForwardDetails.push(detail);
this.logger.showUser(chalk.green(detail));
} else {
const missingDetail: string = `⚠ ${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 = NamespaceName.of(namespace);
const podName: PodName | null = await this.getPodNameForComponent(
component,
type,
k8Client,
namespaceName,
);
if (podName) {
// Re-enable port forward
const podReference: 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: string = ` ↳ Restored port forward for ${componentLabel}`;
this.logger.showUser(chalk.green(restoredDetail));
restoredCount++;
} else {
const errorDetail: string = ` ↳ Could not find pod for ${componentLabel}`;
this.logger.showUser(chalk.red(errorDetail));
}
} catch (error) {
const errorDetail: string = ` ↳ 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: Error | unknown) {
throw new SoloError('Error refreshing port-forwards', error);
}
return true;
}
/**
* Check if a port-forward process is running on the specified port
*/
private async isPortForwardRunning(port: number): Promise<boolean> {
// Validate port before process matching.
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
throw new SoloError(`Invalid port number: ${port}`);
}
try {
const foundProcess: ProcessInfo[] = await find('name', 'port-forward', {skipSelf: true});
return foundProcess.some((process: ProcessInfo): boolean => {
const command: string = (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.
*/
public async showDeploymentStatus(argv: ArgvStruct): Promise<boolean> {
interface Config {
quiet: boolean;
deployment: DeploymentName | undefined;
}
interface PortStatusContext {
config: Config;
deployments: Deployment[];
}
const tasks: SoloListr<PortStatusContext> = new Listr(
[
{
title: 'Initialize',
task: async (context_): Promise<void> => {
await this.localConfig.load();
this.configManager.update(argv);
context_.config = {
quiet: this.configManager.getFlag<boolean>(flags.quiet),
deployment: this.configManager.getFlag<DeploymentName>(flags.deployment),
} as Config;
if (context_.config.deployment) {
const deployment: 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: Deployment[] = [];
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): Promise<void> => {
// 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: number = 0;
let grandRunning: number = 0;
let grandNotRunning: number = 0;
for (const deployment of conte