UNPKG

@hashgraph/solo

Version:

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

590 lines 33.7 kB
/** * 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