@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
590 lines • 33.7 kB
JavaScript
/**
* SPDX-License-Identifier: Apache-2.0
*/
import { ListrEnquirerPromptAdapter } from '@listr2/prompt-adapter-enquirer';
import { Listr } from 'listr2';
import { IllegalArgumentError, MissingArgumentError, SoloError } from '../core/errors.js';
import * as constants from '../core/constants.js';
import { BaseCommand } from './base.js';
import { Flags as flags } from './flags.js';
import { resolveNamespaceFromDeployment } from '../core/resolvers.js';
import * as helpers from '../core/helpers.js';
import { PodName } from '../core/kube/resources/pod/pod_name.js';
import { ListrLease } from '../core/lease/listr_lease.js';
import { ComponentType } from '../core/config/remote/enumerations.js';
import { MirrorNodeComponent } from '../core/config/remote/components/mirror_node_component.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as Base64 from 'js-base64';
import { PodRef } from '../core/kube/resources/pod/pod_ref.js';
import { ContainerName } from '../core/kube/resources/container/container_name.js';
import { ContainerRef } from '../core/kube/resources/container/container_ref.js';
import chalk from 'chalk';
import { PvcRef } from '../core/kube/resources/pvc/pvc_ref.js';
import { PvcName } from '../core/kube/resources/pvc/pvc_name.js';
import { extractContextFromConsensusNodes } from '../core/helpers.js';
export class MirrorNodeCommand extends BaseCommand {
accountManager;
profileManager;
constructor(opts) {
super(opts);
if (!opts || !opts.accountManager)
throw new IllegalArgumentError('An instance of core/AccountManager is required', opts.accountManager);
if (!opts || !opts.profileManager)
throw new MissingArgumentError('An instance of core/ProfileManager is required', opts.downloader);
this.accountManager = opts.accountManager;
this.profileManager = opts.profileManager;
}
static get DEPLOY_CONFIGS_NAME() {
return 'deployConfigs';
}
static get DEPLOY_FLAGS_LIST() {
return [
flags.clusterRef,
flags.chartDirectory,
flags.deployment,
flags.profileFile,
flags.profileName,
flags.quiet,
flags.valuesFile,
flags.mirrorNodeVersion,
flags.pinger,
flags.useExternalDatabase,
flags.operatorId,
flags.operatorKey,
flags.storageType,
flags.storageAccessKey,
flags.storageSecrets,
flags.storageEndpoint,
flags.storageBucket,
flags.storageBucketPrefix,
flags.externalDatabaseHost,
flags.externalDatabaseOwnerUsername,
flags.externalDatabaseOwnerPassword,
flags.externalDatabaseReadonlyUsername,
flags.externalDatabaseReadonlyPassword,
];
}
async prepareValuesArg(config) {
let valuesArg = '';
const profileName = this.configManager.getFlag(flags.profileName);
const profileValuesFile = await this.profileManager.prepareValuesForMirrorNodeChart(profileName);
if (profileValuesFile) {
valuesArg += this.prepareValuesFiles(profileValuesFile);
}
if (config.valuesFile) {
valuesArg += this.prepareValuesFiles(config.valuesFile);
}
if (config.storageBucket) {
valuesArg += ` --set importer.config.hedera.mirror.importer.downloader.bucketName=${config.storageBucket}`;
}
if (config.storageBucketPrefix) {
this.logger.info(`Setting storage bucket prefix to ${config.storageBucketPrefix}`);
valuesArg += ` --set importer.config.hedera.mirror.importer.downloader.pathPrefix=${config.storageBucketPrefix}`;
}
let storageType = '';
if (config.storageType !== constants.StorageType.MINIO_ONLY &&
config.storageAccessKey &&
config.storageSecrets &&
config.storageEndpoint) {
if (config.storageType === constants.StorageType.GCS_ONLY ||
config.storageType === constants.StorageType.AWS_AND_GCS) {
storageType = 'gcp';
}
else if (config.storageType === constants.StorageType.AWS_ONLY) {
storageType = 's3';
}
else {
throw new IllegalArgumentError(`Invalid cloud storage type: ${config.storageType}`);
}
valuesArg += ` --set importer.env.HEDERA_MIRROR_IMPORTER_DOWNLOADER_SOURCES_0_TYPE=${storageType}`;
valuesArg += ` --set importer.env.HEDERA_MIRROR_IMPORTER_DOWNLOADER_SOURCES_0_URI=${config.storageEndpoint}`;
valuesArg += ` --set importer.env.HEDERA_MIRROR_IMPORTER_DOWNLOADER_SOURCES_0_CREDENTIALS_ACCESSKEY=${config.storageAccessKey}`;
valuesArg += ` --set importer.env.HEDERA_MIRROR_IMPORTER_DOWNLOADER_SOURCES_0_CREDENTIALS_SECRETKEY=${config.storageSecrets}`;
}
// if the useExternalDatabase populate all the required values before installing the chart
if (config.useExternalDatabase) {
const { externalDatabaseHost: host, externalDatabaseOwnerUsername: ownerUsername, externalDatabaseOwnerPassword: ownerPassword, externalDatabaseReadonlyUsername: readonlyUsername, externalDatabaseReadonlyPassword: readonlyPassword, } = config;
valuesArg += helpers.populateHelmArgs({
// Disable default database deployment
'stackgres.enabled': false,
'postgresql.enabled': false,
// Set the host and name
'db.host': host,
'db.name': 'mirror_node',
// set the usernames
'db.owner.username': ownerUsername,
'importer.db.username': ownerUsername,
'grpc.db.username': readonlyUsername,
'restjava.db.username': readonlyUsername,
'web3.db.username': readonlyUsername,
// TODO: Fixes a problem where importer's V1.0__Init.sql migration fails
// 'rest.db.username': readonlyUsername,
// set the passwords
'db.owner.password': ownerPassword,
'importer.db.password': ownerPassword,
'grpc.db.password': readonlyPassword,
'restjava.db.password': readonlyPassword,
'web3.db.password': readonlyPassword,
'rest.db.password': readonlyPassword,
});
}
return valuesArg;
}
async deploy(argv) {
const self = this;
const lease = await self.leaseManager.create();
const tasks = new Listr([
{
title: 'Initialize',
task: async (ctx, task) => {
self.configManager.update(argv);
// disable the prompts that we don't want to prompt the user for
flags.disablePrompts([
flags.clusterRef,
flags.valuesFile,
flags.mirrorNodeVersion,
flags.pinger,
flags.operatorId,
flags.operatorKey,
flags.useExternalDatabase,
flags.externalDatabaseHost,
flags.externalDatabaseOwnerUsername,
flags.externalDatabaseOwnerPassword,
flags.externalDatabaseReadonlyUsername,
flags.externalDatabaseReadonlyPassword,
]);
await self.configManager.executePrompt(task, MirrorNodeCommand.DEPLOY_FLAGS_LIST);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
ctx.config = this.getConfig(MirrorNodeCommand.DEPLOY_CONFIGS_NAME, MirrorNodeCommand.DEPLOY_FLAGS_LIST, [
'chartPath',
'valuesArg',
'namespace',
]);
ctx.config.namespace = namespace;
ctx.config.chartPath = await self.prepareChartPath('', // don't use chartPath which is for local solo-charts only
constants.MIRROR_NODE_RELEASE_NAME, constants.MIRROR_NODE_CHART);
// predefined values first
ctx.config.valuesArg += this.prepareValuesFiles(constants.MIRROR_NODE_VALUES_FILE);
// user defined values later to override predefined values
ctx.config.valuesArg += await self.prepareValuesArg(ctx.config);
const clusterRef = this.configManager.getFlag(flags.clusterRef);
ctx.config.clusterContext = clusterRef
? this.getLocalConfig().clusterRefs[clusterRef]
: this.k8Factory.default().contexts().readCurrent();
await self.accountManager.loadNodeClient(ctx.config.namespace, self.getClusterRefs(), self.configManager.getFlag(flags.deployment), self.configManager.getFlag(flags.forcePortForward), ctx.config.clusterContext);
if (ctx.config.pinger) {
const startAccId = constants.HEDERA_NODE_ACCOUNT_ID_START;
const networkPods = await this.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.list(namespace, ['solo.hedera.com/type=network-node']);
if (networkPods.length) {
const pod = networkPods[0];
ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.nodes.0.accountId=${startAccId}`;
ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.nodes.0.host=${pod.status.podIP}`;
ctx.config.valuesArg += ' --set monitor.config.hedera.mirror.monitor.nodes.0.nodeId=0';
const operatorId = ctx.config.operatorId || constants.OPERATOR_ID;
ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.operator.accountId=${operatorId}`;
if (ctx.config.operatorKey) {
this.logger.info('Using provided operator key');
ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.operator.privateKey=${ctx.config.operatorKey}`;
}
else {
try {
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
const secrets = await this.k8Factory
.getK8(ctx.config.clusterContext)
.secrets()
.list(namespace, [`solo.hedera.com/account-id=${operatorId}`]);
if (secrets.length === 0) {
this.logger.info(`No k8s secret found for operator account id ${operatorId}, use default one`);
ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.operator.privateKey=${constants.OPERATOR_KEY}`;
}
else {
this.logger.info('Using operator key from k8s secret');
const operatorKeyFromK8 = Base64.decode(secrets[0].data.privateKey);
ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.operator.privateKey=${operatorKeyFromK8}`;
}
}
catch (e) {
throw new SoloError(`Error getting operator key: ${e.message}`, e);
}
}
}
}
const isQuiet = ctx.config.quiet;
// In case the useExternalDatabase is set, prompt for the rest of the required data
if (ctx.config.useExternalDatabase && !isQuiet) {
await self.configManager.executePrompt(task, [
flags.externalDatabaseHost,
flags.externalDatabaseOwnerUsername,
flags.externalDatabaseOwnerPassword,
flags.externalDatabaseReadonlyUsername,
flags.externalDatabaseReadonlyPassword,
]);
}
else if (ctx.config.useExternalDatabase &&
(!ctx.config.externalDatabaseHost ||
!ctx.config.externalDatabaseOwnerUsername ||
!ctx.config.externalDatabaseOwnerPassword ||
!ctx.config.externalDatabaseReadonlyUsername ||
!ctx.config.externalDatabaseReadonlyPassword)) {
const missingFlags = [];
if (!ctx.config.externalDatabaseHost)
missingFlags.push(flags.externalDatabaseHost);
if (!ctx.config.externalDatabaseOwnerUsername)
missingFlags.push(flags.externalDatabaseOwnerUsername);
if (!ctx.config.externalDatabaseOwnerPassword)
missingFlags.push(flags.externalDatabaseOwnerPassword);
if (!ctx.config.externalDatabaseReadonlyUsername) {
missingFlags.push(flags.externalDatabaseReadonlyUsername);
}
if (!ctx.config.externalDatabaseReadonlyPassword) {
missingFlags.push(flags.externalDatabaseReadonlyPassword);
}
if (missingFlags.length) {
const errorMessage = 'There are missing values that need to be provided when' +
`${chalk.cyan(`--${flags.useExternalDatabase.name}`)} is provided: `;
throw new SoloError(`${errorMessage} ${missingFlags.map(flag => `--${flag.name}`).join(', ')}`);
}
}
if (!(await self.k8Factory.getK8(ctx.config.clusterContext).namespaces().has(ctx.config.namespace))) {
throw new SoloError(`namespace ${ctx.config.namespace} does not exist`);
}
return ListrLease.newAcquireLeaseTask(lease, task);
},
},
{
title: 'Enable mirror-node',
task: (_, parentTask) => {
return parentTask.newListr([
{
title: 'Prepare address book',
task: async (ctx) => {
const deployment = this.configManager.getFlag(flags.deployment);
const portForward = this.configManager.getFlag(flags.forcePortForward);
const consensusNodes = this.getConsensusNodes();
const nodeAlias = `node${consensusNodes[0].nodeId}`;
const context = extractContextFromConsensusNodes(nodeAlias, consensusNodes);
ctx.addressBook = await self.accountManager.prepareAddressBookBase64(ctx.config.namespace, this.getClusterRefs(), deployment, this.configManager.getFlag(flags.operatorId), this.configManager.getFlag(flags.operatorKey), portForward, context);
ctx.config.valuesArg += ` --set "importer.addressBook=${ctx.addressBook}"`;
},
},
{
title: 'Deploy mirror-node',
task: async (ctx) => {
await self.chartManager.install(ctx.config.namespace, constants.MIRROR_NODE_RELEASE_NAME, ctx.config.chartPath, ctx.config.mirrorNodeVersion, ctx.config.valuesArg, ctx.config.clusterContext);
},
},
], {
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
});
},
},
{
title: 'Check pods are ready',
task: (_, parentTask) => {
return parentTask.newListr([
{
title: 'Check Postgres DB',
task: async (ctx) => await self.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.waitForReadyStatus(ctx.config.namespace, ['app.kubernetes.io/component=postgresql', 'app.kubernetes.io/name=postgres'], constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY),
skip: ctx => !!ctx.config.useExternalDatabase,
},
{
title: 'Check REST API',
task: async (ctx) => await self.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.waitForReadyStatus(ctx.config.namespace, ['app.kubernetes.io/component=rest', 'app.kubernetes.io/name=rest'], constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY),
},
{
title: 'Check GRPC',
task: async (ctx) => await self.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.waitForReadyStatus(ctx.config.namespace, ['app.kubernetes.io/component=grpc', 'app.kubernetes.io/name=grpc'], constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY),
},
{
title: 'Check Monitor',
task: async (ctx) => await self.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.waitForReadyStatus(ctx.config.namespace, ['app.kubernetes.io/component=monitor', 'app.kubernetes.io/name=monitor'], constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY),
},
{
title: 'Check Importer',
task: async (ctx) => await self.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.waitForReadyStatus(ctx.config.namespace, ['app.kubernetes.io/component=importer', 'app.kubernetes.io/name=importer'], constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY),
},
], {
concurrent: true,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
});
},
},
{
title: 'Seed DB data',
task: (_, parentTask) => {
return parentTask.newListr([
{
title: 'Insert data in public.file_data',
task: async (ctx) => {
const namespace = ctx.config.namespace;
const feesFileIdNum = 111;
const exchangeRatesFileIdNum = 112;
const timestamp = Date.now();
const clusterRefs = this.getClusterRefs();
const deployment = this.configManager.getFlag(flags.deployment);
const fees = await this.accountManager.getFileContents(namespace, feesFileIdNum, clusterRefs, deployment, this.configManager.getFlag(flags.forcePortForward));
const exchangeRates = await this.accountManager.getFileContents(namespace, exchangeRatesFileIdNum, clusterRefs, deployment, this.configManager.getFlag(flags.forcePortForward));
const importFeesQuery = `INSERT INTO public.file_data(file_data, consensus_timestamp, entity_id,
transaction_type)
VALUES (decode('${fees}', 'hex'), ${timestamp + '000000'},
${feesFileIdNum}, 17);`;
const importExchangeRatesQuery = `INSERT INTO public.file_data(file_data, consensus_timestamp,
entity_id, transaction_type)
VALUES (decode('${exchangeRates}', 'hex'), ${timestamp + '000001'}, ${exchangeRatesFileIdNum}, 17);`;
const sqlQuery = [importFeesQuery, importExchangeRatesQuery].join('\n');
// When useExternalDatabase flag is enabled, the query is not executed,
// but exported to the specified path inside the cache directory,
// and the user has the responsibility to execute it manually on his own
if (ctx.config.useExternalDatabase) {
// Build the path
const databaseSeedingQueryPath = path.join(constants.SOLO_CACHE_DIR, 'database-seeding-query.sql');
// Write the file database seeding query inside the cache
fs.writeFileSync(databaseSeedingQueryPath, sqlQuery);
// Notify the user
self.logger.showUser(chalk.cyan('Please run the following SQL script against the external database ' +
'to enable Mirror Node to function correctly:'), chalk.yellow(databaseSeedingQueryPath));
return; //! stop the execution
}
const pods = await this.k8Factory
.getK8(ctx.config.clusterContext)
.pods()
.list(namespace, ['app.kubernetes.io/name=postgres']);
if (pods.length === 0) {
throw new SoloError('postgres pod not found');
}
const postgresPodName = PodName.of(pods[0].metadata.name);
const postgresContainerName = ContainerName.of('postgresql');
const postgresPodRef = PodRef.of(namespace, postgresPodName);
const containerRef = ContainerRef.of(postgresPodRef, postgresContainerName);
const mirrorEnvVars = await self.k8Factory
.getK8(ctx.config.clusterContext)
.containers()
.readByRef(containerRef)
.execContainer('/bin/bash -c printenv');
const mirrorEnvVarsArray = mirrorEnvVars.split('\n');
const HEDERA_MIRROR_IMPORTER_DB_OWNER = helpers.getEnvValue(mirrorEnvVarsArray, 'HEDERA_MIRROR_IMPORTER_DB_OWNER');
const HEDERA_MIRROR_IMPORTER_DB_OWNERPASSWORD = helpers.getEnvValue(mirrorEnvVarsArray, 'HEDERA_MIRROR_IMPORTER_DB_OWNERPASSWORD');
const HEDERA_MIRROR_IMPORTER_DB_NAME = helpers.getEnvValue(mirrorEnvVarsArray, 'HEDERA_MIRROR_IMPORTER_DB_NAME');
await self.k8Factory
.getK8(ctx.config.clusterContext)
.containers()
.readByRef(containerRef)
.execContainer([
'psql',
`postgresql://${HEDERA_MIRROR_IMPORTER_DB_OWNER}:${HEDERA_MIRROR_IMPORTER_DB_OWNERPASSWORD}@localhost:5432/${HEDERA_MIRROR_IMPORTER_DB_NAME}`,
'-c',
sqlQuery,
]);
},
},
], {
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
});
},
},
this.addMirrorNodeComponents(),
], {
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
});
try {
await tasks.run();
self.logger.debug('mirror node deployment has completed');
}
catch (e) {
const message = `Error deploying node: ${e.message}`;
self.logger.error(message, e);
throw new SoloError(message, e);
}
finally {
await lease.release();
await self.accountManager.close();
}
return true;
}
async destroy(argv) {
const self = this;
const lease = await self.leaseManager.create();
const tasks = new Listr([
{
title: 'Initialize',
task: async (ctx, task) => {
if (!argv.force) {
const confirm = await task.prompt(ListrEnquirerPromptAdapter).run({
type: 'toggle',
default: false,
message: 'Are you sure you would like to destroy the mirror-node components?',
});
if (!confirm) {
process.exit(0);
}
}
self.configManager.update(argv);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
const clusterRef = this.configManager.getFlag(flags.clusterRef);
const clusterContext = clusterRef
? this.getLocalConfig().clusterRefs[clusterRef]
: this.k8Factory.default().contexts().readCurrent();
if (!(await self.k8Factory.getK8(clusterContext).namespaces().has(namespace))) {
throw new SoloError(`namespace ${namespace} does not exist`);
}
const isChartInstalled = await this.chartManager.isChartInstalled(namespace, constants.MIRROR_NODE_RELEASE_NAME);
ctx.config = {
clusterContext,
namespace,
isChartInstalled,
};
await self.accountManager.loadNodeClient(ctx.config.namespace, self.getClusterRefs(), self.configManager.getFlag(flags.deployment), self.configManager.getFlag(flags.forcePortForward), ctx.config.clusterContext);
return ListrLease.newAcquireLeaseTask(lease, task);
},
},
{
title: 'Destroy mirror-node',
task: async (ctx) => {
await this.chartManager.uninstall(ctx.config.namespace, constants.MIRROR_NODE_RELEASE_NAME, ctx.config.clusterContext);
},
skip: ctx => !ctx.config.isChartInstalled,
},
{
title: 'Delete PVCs',
task: async (ctx) => {
// filtering postgres and redis PVCs using instance labels
// since they have different name or component labels
const pvcs = await self.k8Factory
.getK8(ctx.config.clusterContext)
.pvcs()
.list(ctx.config.namespace, [`app.kubernetes.io/instance=${constants.MIRROR_NODE_RELEASE_NAME}`]);
if (pvcs) {
for (const pvc of pvcs) {
await self.k8Factory
.getK8(ctx.config.clusterContext)
.pvcs()
.delete(PvcRef.of(ctx.config.namespace, PvcName.of(pvc)));
}
}
},
skip: ctx => !ctx.config.isChartInstalled,
},
this.removeMirrorNodeComponents(),
], {
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
});
try {
await tasks.run();
self.logger.debug('mirror node destruction has completed');
}
catch (e) {
throw new SoloError(`Error destroying mirror node: ${e.message}`, e);
}
finally {
await lease.release();
await self.accountManager.close();
}
return true;
}
/** Return Yargs command definition for 'mirror-mirror-node' command */
getCommandDefinition() {
const self = this;
return {
command: 'mirror-node',
desc: 'Manage Hedera Mirror Node in solo network',
builder: yargs => {
return yargs
.command({
command: 'deploy',
desc: 'Deploy mirror-node and its components',
builder: y => flags.setCommandFlags(y, ...MirrorNodeCommand.DEPLOY_FLAGS_LIST),
handler: argv => {
self.logger.info("==== Running 'mirror-node deploy' ===");
self.logger.info(argv);
self
.deploy(argv)
.then(r => {
self.logger.info('==== Finished running `mirror-node deploy`====');
if (!r)
process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.command({
command: 'destroy',
desc: 'Destroy mirror-node components and database',
builder: y => flags.setCommandFlags(y, flags.chartDirectory, flags.clusterRef, flags.force, flags.quiet, flags.deployment),
handler: argv => {
self.logger.info("==== Running 'mirror-node destroy' ===");
self.logger.info(argv);
self
.destroy(argv)
.then(r => {
self.logger.info('==== Finished running `mirror-node destroy`====');
if (!r)
process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.demandCommand(1, 'Select a mirror-node command');
},
};
}
/** Removes the mirror node components from remote config. */
removeMirrorNodeComponents() {
return {
title: 'Remove mirror node from remote config',
skip: () => !this.remoteConfigManager.isLoaded(),
task: async () => {
await this.remoteConfigManager.modify(async (remoteConfig) => {
remoteConfig.components.remove('mirrorNode', ComponentType.MirrorNode);
});
},
};
}
/** Adds the mirror node components to remote config. */
addMirrorNodeComponents() {
return {
title: 'Add mirror node to remote config',
skip: () => !this.remoteConfigManager.isLoaded(),
task: async (ctx) => {
await this.remoteConfigManager.modify(async (remoteConfig) => {
const { config: { namespace }, } = ctx;
const cluster = this.remoteConfigManager.currentCluster;
remoteConfig.components.add('mirrorNode', new MirrorNodeComponent('mirrorNode', cluster, namespace.name));
});
},
};
}
close() {
// no-op
return Promise.resolve();
}
}
//# sourceMappingURL=mirror_node.js.map