@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
522 lines (452 loc) • 17.5 kB
text/typescript
/**
* SPDX-License-Identifier: Apache-2.0
*/
import {Listr, type ListrTask} from 'listr2';
import {MissingArgumentError, SoloError} from '../core/errors.js';
import * as helpers from '../core/helpers.js';
import {getNodeAccountMap} from '../core/helpers.js';
import * as constants from '../core/constants.js';
import {type ProfileManager} from '../core/profile_manager.js';
import {type AccountManager} from '../core/account_manager.js';
import {BaseCommand, type Opts} from './base.js';
import {Flags as flags} from './flags.js';
import {resolveNamespaceFromDeployment} from '../core/resolvers.js';
import {type CommandBuilder, type NodeAliases} from '../types/aliases.js';
import {ListrLease} from '../core/lease/listr_lease.js';
import {RelayComponent} from '../core/config/remote/components/relay_component.js';
import {ComponentType} from '../core/config/remote/enumerations.js';
import * as Base64 from 'js-base64';
import {NamespaceName} from '../core/kube/resources/namespace/namespace_name.js';
import {type ClusterRef, type DeploymentName} from '../core/config/remote/types.js';
import {type Optional} from '../types/index.js';
export class RelayCommand extends BaseCommand {
private readonly profileManager: ProfileManager;
private readonly accountManager: AccountManager;
constructor(opts: Opts) {
super(opts);
if (!opts || !opts.profileManager)
throw new MissingArgumentError('An instance of core/ProfileManager is required', opts.downloader);
this.profileManager = opts.profileManager;
this.accountManager = opts.accountManager;
}
static get DEPLOY_CONFIGS_NAME() {
return 'deployConfigs';
}
static get DEPLOY_FLAGS_LIST() {
return [
flags.chainId,
flags.chartDirectory,
flags.clusterRef,
flags.deployment,
flags.nodeAliasesUnparsed,
flags.operatorId,
flags.operatorKey,
flags.profileFile,
flags.profileName,
flags.quiet,
flags.relayReleaseTag,
flags.replicaCount,
flags.valuesFile,
];
}
static get DESTROY_FLAGS_LIST() {
return [flags.chartDirectory, flags.deployment, flags.nodeAliasesUnparsed];
}
async prepareValuesArg(
valuesFile: string,
nodeAliases: NodeAliases,
chainID: string,
relayRelease: string,
replicaCount: number,
operatorID: string,
operatorKey: string,
namespace: NamespaceName,
context?: Optional<string>,
) {
let valuesArg = '';
const profileName = this.configManager.getFlag<string>(flags.profileName) as string;
const profileValuesFile = await this.profileManager.prepareValuesForRpcRelayChart(profileName);
if (profileValuesFile) {
valuesArg += this.prepareValuesFiles(profileValuesFile);
}
valuesArg += ` --set config.MIRROR_NODE_URL=http://${constants.MIRROR_NODE_RELEASE_NAME}-rest`;
valuesArg += ` --set config.MIRROR_NODE_URL_WEB3=http://${constants.MIRROR_NODE_RELEASE_NAME}-web3`;
valuesArg += ' --set config.MIRROR_NODE_AGENT_CACHEABLE_DNS=false';
valuesArg += ' --set config.MIRROR_NODE_RETRY_DELAY=2001';
valuesArg += ' --set config.MIRROR_NODE_GET_CONTRACT_RESULTS_DEFAULT_RETRIES=21';
if (chainID) {
valuesArg += ` --set config.CHAIN_ID=${chainID}`;
}
if (relayRelease) {
valuesArg += ` --set image.tag=${relayRelease.replace(/^v/, '')}`;
}
if (replicaCount) {
valuesArg += ` --set replicaCount=${replicaCount}`;
}
const operatorIdUsing = operatorID || constants.OPERATOR_ID;
valuesArg += ` --set config.OPERATOR_ID_MAIN=${operatorIdUsing}`;
if (operatorKey) {
// use user provided operatorKey if available
valuesArg += ` --set config.OPERATOR_KEY_MAIN=${operatorKey}`;
} else {
try {
const deploymentName = this.configManager.getFlag<DeploymentName>(flags.deployment);
const namespace = NamespaceName.of(this.localConfig.deployments[deploymentName].namespace);
const k8 = this.k8Factory.getK8(context);
const secrets = await k8.secrets().list(namespace, [`solo.hedera.com/account-id=${operatorIdUsing}`]);
if (secrets.length === 0) {
this.logger.info(`No k8s secret found for operator account id ${operatorIdUsing}, use default one`);
valuesArg += ` --set config.OPERATOR_KEY_MAIN=${constants.OPERATOR_KEY}`;
} else {
this.logger.info('Using operator key from k8s secret');
const operatorKeyFromK8 = Base64.decode(secrets[0].data.privateKey);
valuesArg += ` --set config.OPERATOR_KEY_MAIN=${operatorKeyFromK8}`;
}
} catch (e) {
throw new SoloError(`Error getting operator key: ${e.message}`, e);
}
}
if (!nodeAliases) {
throw new MissingArgumentError('Node IDs must be specified');
}
const networkJsonString = await this.prepareNetworkJsonString(nodeAliases, namespace);
valuesArg += ` --set config.HEDERA_NETWORK='${networkJsonString}'`;
if (valuesFile) {
valuesArg += this.prepareValuesFiles(valuesFile);
}
return valuesArg;
}
/**
* created a json string to represent the map between the node keys and their ids
* output example '{"node-1": "0.0.3", "node-2": "0.004"}'
*/
async prepareNetworkJsonString(nodeAliases: NodeAliases = [], namespace: NamespaceName) {
if (!nodeAliases) {
throw new MissingArgumentError('Node IDs must be specified');
}
const networkIds = {};
const accountMap = getNodeAccountMap(nodeAliases);
const deploymentName = this.configManager.getFlag<DeploymentName>(flags.deployment);
const networkNodeServicesMap = await this.accountManager.getNodeServiceMap(
namespace,
this.getClusterRefs(),
deploymentName,
);
nodeAliases.forEach(nodeAlias => {
const haProxyClusterIp = networkNodeServicesMap.get(nodeAlias).haProxyClusterIp;
const haProxyGrpcPort = networkNodeServicesMap.get(nodeAlias).haProxyGrpcPort;
const networkKey = `${haProxyClusterIp}:${haProxyGrpcPort}`;
networkIds[networkKey] = accountMap.get(nodeAlias);
});
return JSON.stringify(networkIds);
}
prepareReleaseName(nodeAliases: NodeAliases = []) {
if (!nodeAliases) {
throw new MissingArgumentError('Node IDs must be specified');
}
let releaseName = 'relay';
nodeAliases.forEach(nodeAlias => {
releaseName += `-${nodeAlias}`;
});
return releaseName;
}
async deploy(argv: any) {
const self = this;
const lease = await self.leaseManager.create();
interface RelayDeployConfigClass {
chainId: string;
chartDirectory: string;
namespace: NamespaceName;
deployment: string;
nodeAliasesUnparsed: string;
operatorId: string;
operatorKey: string;
profileFile: string;
profileName: string;
relayReleaseTag: string;
replicaCount: number;
valuesFile: string;
chartPath: string;
isChartInstalled: boolean;
nodeAliases: NodeAliases;
releaseName: string;
valuesArg: string;
clusterRef: Optional<ClusterRef>;
context: Optional<string>;
getUnusedConfigs: () => string[];
}
interface Context {
config: RelayDeployConfigClass;
}
const tasks = new Listr<Context>(
[
{
title: 'Initialize',
task: async (ctx, task) => {
// reset nodeAlias
self.configManager.setFlag(flags.nodeAliasesUnparsed, '');
self.configManager.update(argv);
flags.disablePrompts([flags.operatorId, flags.operatorKey, flags.clusterRef]);
await self.configManager.executePrompt(task, RelayCommand.DEPLOY_FLAGS_LIST);
// prompt if inputs are empty and set it in the context
ctx.config = this.getConfig(RelayCommand.DEPLOY_CONFIGS_NAME, RelayCommand.DEPLOY_FLAGS_LIST, [
'nodeAliases',
]) as RelayDeployConfigClass;
ctx.config.namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
ctx.config.nodeAliases = helpers.parseNodeAliases(ctx.config.nodeAliasesUnparsed);
ctx.config.releaseName = self.prepareReleaseName(ctx.config.nodeAliases);
if (ctx.config.clusterRef) {
const context = self.getClusterRefs()[ctx.config.clusterRef];
if (context) ctx.config.context = context;
}
ctx.config.isChartInstalled = await self.chartManager.isChartInstalled(
ctx.config.namespace,
ctx.config.releaseName,
ctx.config.context,
);
self.logger.debug('Initialized config', {config: ctx.config});
return ListrLease.newAcquireLeaseTask(lease, task);
},
},
{
title: 'Prepare chart values',
task: async ctx => {
const config = ctx.config;
config.chartPath = await self.prepareChartPath(
config.chartDirectory,
constants.JSON_RPC_RELAY_CHART,
constants.JSON_RPC_RELAY_CHART,
);
await self.accountManager.loadNodeClient(
ctx.config.namespace,
self.getClusterRefs(),
self.configManager.getFlag<DeploymentName>(flags.deployment),
self.configManager.getFlag<boolean>(flags.forcePortForward),
ctx.config.context,
);
config.valuesArg = await self.prepareValuesArg(
config.valuesFile,
config.nodeAliases,
config.chainId,
config.relayReleaseTag,
config.replicaCount,
config.operatorId,
config.operatorKey,
config.namespace,
config.context,
);
},
},
{
title: 'Deploy JSON RPC Relay',
task: async ctx => {
const config = ctx.config;
const k8 = self.k8Factory.getK8(config.context);
const kubeContext = k8.contexts().readCurrent();
await self.chartManager.install(
config.namespace,
config.releaseName,
config.chartPath,
'',
config.valuesArg,
kubeContext,
);
await k8
.pods()
.waitForRunningPhase(
config.namespace,
['app=hedera-json-rpc-relay', `app.kubernetes.io/instance=${config.releaseName}`],
constants.RELAY_PODS_RUNNING_MAX_ATTEMPTS,
constants.RELAY_PODS_RUNNING_DELAY,
);
// reset nodeAlias
self.configManager.setFlag(flags.nodeAliasesUnparsed, '');
},
},
{
title: 'Check relay is ready',
task: async ctx => {
const config = ctx.config;
const k8 = self.k8Factory.getK8(config.context);
try {
await k8
.pods()
.waitForReadyStatus(
config.namespace,
['app=hedera-json-rpc-relay', `app.kubernetes.io/instance=${config.releaseName}`],
constants.RELAY_PODS_READY_MAX_ATTEMPTS,
constants.RELAY_PODS_READY_DELAY,
);
} catch (e) {
throw new SoloError(`Relay ${config.releaseName} is not ready: ${e.message}`, e);
}
},
},
this.addRelayComponent(),
],
{
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
},
);
try {
await tasks.run();
} catch (e) {
throw new SoloError('Error installing relays', e);
} finally {
await lease.release();
await self.accountManager.close();
}
return true;
}
async destroy(argv: any) {
const self = this;
const lease = await self.leaseManager.create();
interface RelayDestroyConfigClass {
chartDirectory: string;
namespace: NamespaceName;
deployment: string;
nodeAliases: NodeAliases;
releaseName: string;
isChartInstalled: boolean;
}
interface Context {
config: RelayDestroyConfigClass;
}
const tasks = new Listr<Context>(
[
{
title: 'Initialize',
task: async (ctx, task) => {
// reset nodeAlias
self.configManager.setFlag(flags.nodeAliasesUnparsed, '');
self.configManager.update(argv);
await self.configManager.executePrompt(task, RelayCommand.DESTROY_FLAGS_LIST);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
// prompt if inputs are empty and set it in the context
ctx.config = {
chartDirectory: self.configManager.getFlag<string>(flags.chartDirectory) as string,
namespace: namespace,
nodeAliases: helpers.parseNodeAliases(
self.configManager.getFlag<string>(flags.nodeAliasesUnparsed) as string,
),
} as RelayDestroyConfigClass;
ctx.config.releaseName = this.prepareReleaseName(ctx.config.nodeAliases);
ctx.config.isChartInstalled = await this.chartManager.isChartInstalled(
ctx.config.namespace,
ctx.config.releaseName,
);
self.logger.debug('Initialized config', {config: ctx.config});
return ListrLease.newAcquireLeaseTask(lease, task);
},
},
{
title: 'Destroy JSON RPC Relay',
task: async ctx => {
const config = ctx.config;
await this.chartManager.uninstall(
config.namespace,
config.releaseName,
this.k8Factory.default().contexts().readCurrent(),
);
this.logger.showList('Destroyed Relays', await self.chartManager.getInstalledCharts(config.namespace));
// reset nodeAliasesUnparsed
self.configManager.setFlag(flags.nodeAliasesUnparsed, '');
},
skip: ctx => !ctx.config.isChartInstalled,
},
this.removeRelayComponent(),
],
{
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
},
);
try {
await tasks.run();
} catch (e: Error | any) {
throw new SoloError('Error uninstalling relays', e);
} finally {
await lease.release();
}
return true;
}
getCommandDefinition(): {command: string; desc: string; builder: CommandBuilder} {
const self = this;
return {
command: 'relay',
desc: 'Manage JSON RPC relays in solo network',
builder: (yargs: any) => {
return yargs
.command({
command: 'deploy',
desc: 'Deploy a JSON RPC relay',
builder: (y: any) => {
flags.setCommandFlags(y, ...RelayCommand.DEPLOY_FLAGS_LIST);
},
handler: (argv: any) => {
self.logger.info("==== Running 'relay deploy' ===", {argv});
self.logger.info(argv);
self
.deploy(argv)
.then(r => {
self.logger.info('==== Finished running `relay deploy`====');
if (!r) process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.command({
command: 'destroy',
desc: 'Destroy JSON RPC relay',
builder: (y: any) =>
flags.setCommandFlags(y, flags.chartDirectory, flags.deployment, flags.quiet, flags.nodeAliasesUnparsed),
handler: (argv: any) => {
self.logger.info("==== Running 'relay destroy' ===", {argv});
self.logger.debug(argv);
self.destroy(argv).then(r => {
self.logger.info('==== Finished running `relay destroy`====');
if (!r) process.exit(1);
});
},
})
.demandCommand(1, 'Select a relay command');
},
};
}
/** Adds the relay component to remote config. */
public addRelayComponent(): ListrTask<any, any, any> {
return {
title: 'Add relay component in remote config',
skip: (): boolean => !this.remoteConfigManager.isLoaded(),
task: async (ctx): Promise<void> => {
await this.remoteConfigManager.modify(async remoteConfig => {
const {
config: {namespace, nodeAliases},
} = ctx;
const cluster = this.remoteConfigManager.currentCluster;
remoteConfig.components.add('relay', new RelayComponent('relay', cluster, namespace.name, nodeAliases));
});
},
};
}
/** Remove the relay component from remote config. */
public removeRelayComponent(): ListrTask<any, any, any> {
return {
title: 'Remove relay component from remote config',
skip: (): boolean => !this.remoteConfigManager.isLoaded(),
task: async (): Promise<void> => {
await this.remoteConfigManager.modify(async remoteConfig => {
remoteConfig.components.remove('relay', ComponentType.Relay);
});
},
};
}
close(): Promise<void> {
// no-op
return Promise.resolve();
}
}