UNPKG

@hashgraph/solo

Version:

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

835 lines 81.8 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var MirrorNodeCommand_1; import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'; import { confirm as confirmPrompt } from '@inquirer/prompts'; import { IllegalArgumentError } from '../core/errors/illegal-argument-error.js'; import { SoloError } from '../core/errors/solo-error.js'; import { UserBreak } from '../core/errors/user-break.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 { prepareValuesFiles, showVersionBanner } from '../core/helpers.js'; import { ListrLock } from '../core/lock/listr-lock.js'; import * as fs from 'node:fs'; import * as versions from '../../version.js'; import { INGRESS_CONTROLLER_VERSION } from '../../version.js'; import chalk from 'chalk'; import { PvcReference } from '../integration/kube/resources/pvc/pvc-reference.js'; import { PvcName } from '../integration/kube/resources/pvc/pvc-name.js'; import { KeyManager } from '../core/key-manager.js'; import { PathEx } from '../business/utils/path-ex.js'; import { inject, injectable } from 'tsyringe-neo'; import { InjectTokens } from '../core/dependency-injection/inject-tokens.js'; import { patchInject } from '../core/dependency-injection/container-helper.js'; import { ComponentTypes } from '../core/config/remote/enumerations/component-types.js'; import { Base64 } from 'js-base64'; import { SemanticVersion } from '../business/utils/semantic-version.js'; import { assertUpgradeVersionNotOlder } from '../core/upgrade-version-guard.js'; import { Templates } from '../core/templates.js'; import yaml from 'yaml'; import { DeploymentPhase } from '../data/schema/model/remote/deployment-phase.js'; import { PostgresSharedResource } from '../core/shared-resources/postgres.js'; import { SharedResourceManager } from '../core/shared-resources/shared-resource-manager.js'; import { MirrorNodeDeployedEvent } from '../core/events/event-types/mirror-node-deployed-event.js'; import { optionFromFlag } from './command-helpers.js'; import { ImageReference } from '../business/utils/image-reference.js'; var MirrorNodeCommandType; (function (MirrorNodeCommandType) { MirrorNodeCommandType["ADD"] = "add"; MirrorNodeCommandType["UPGRADE"] = "upgrade"; MirrorNodeCommandType["DESTROY"] = "destroy"; })(MirrorNodeCommandType || (MirrorNodeCommandType = {})); let MirrorNodeCommand = class MirrorNodeCommand extends BaseCommand { static { MirrorNodeCommand_1 = this; } postgresSharedResource; sharedResourceManager; accountManager; eventBus; static MIRROR_ENVIRONMENT_VARIABLE_PREFIX = 'HIERO'; static MIRROR_CHART_NAMESPACE = 'hiero'; constructor(postgresSharedResource, sharedResourceManager, accountManager, eventBus) { super(); this.postgresSharedResource = postgresSharedResource; this.sharedResourceManager = sharedResourceManager; this.accountManager = accountManager; this.eventBus = eventBus; this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name); this.postgresSharedResource = patchInject(postgresSharedResource, InjectTokens.PostgresSharedResource, this.constructor.name); this.sharedResourceManager = patchInject(sharedResourceManager, InjectTokens.SharedResourceManager, this.constructor.name); } static DEPLOY_CONFIGS_NAME = 'deployConfigs'; static UPGRADE_CONFIGS_NAME = 'upgradeConfigs'; static DEPLOY_FLAGS_LIST = { required: [flags.deployment], optional: [ flags.cacheDir, flags.chartDirectory, flags.mirrorNodeChartDirectory, flags.clusterRef, flags.enableIngress, flags.ingressControllerValueFile, flags.mirrorStaticIp, flags.quiet, flags.valuesFile, flags.mirrorNodeVersion, flags.componentImage, flags.pinger, flags.useExternalDatabase, flags.operatorId, flags.operatorKey, flags.storageType, flags.storageReadAccessKey, flags.storageReadSecrets, flags.storageEndpoint, flags.storageBucket, flags.storageBucketPrefix, flags.storageBucketRegion, flags.externalDatabaseHost, flags.externalDatabaseOwnerUsername, flags.externalDatabaseOwnerPassword, flags.externalDatabaseReadonlyUsername, flags.externalDatabaseReadonlyPassword, flags.domainName, flags.forcePortForward, flags.externalAddress, flags.soloChartVersion, flags.forceBlockNodeIntegration, // Used to bypass version requirements for block node integration flags.parallelDeploy, ], }; static UPGRADE_FLAGS_LIST = { required: [flags.deployment], optional: [ flags.clusterRef, flags.cacheDir, flags.chartDirectory, flags.mirrorNodeChartDirectory, flags.enableIngress, flags.ingressControllerValueFile, flags.mirrorStaticIp, flags.quiet, flags.valuesFile, flags.mirrorNodeVersion, flags.componentImage, flags.pinger, flags.useExternalDatabase, flags.operatorId, flags.operatorKey, flags.storageType, flags.storageReadAccessKey, flags.storageReadSecrets, flags.storageEndpoint, flags.storageBucket, flags.storageBucketPrefix, flags.storageBucketRegion, flags.externalDatabaseHost, flags.externalDatabaseOwnerUsername, flags.externalDatabaseOwnerPassword, flags.externalDatabaseReadonlyUsername, flags.externalDatabaseReadonlyPassword, flags.domainName, flags.forcePortForward, flags.externalAddress, flags.id, flags.soloChartVersion, flags.forceBlockNodeIntegration, // Used to bypass version requirements for block node integration ], }; static DESTROY_FLAGS_LIST = { required: [flags.deployment], optional: [flags.chartDirectory, flags.clusterRef, flags.force, flags.quiet, flags.devMode, flags.id], }; prepareBlockNodeIntegrationValues(config) { const configuration = this.remoteConfig.configuration; const blockNodeSchemas = configuration.components.state.blockNodes; const sameClusterBlockNodeSchemas = blockNodeSchemas.filter((blockNode) => blockNode.metadata.cluster === config.clusterReference); if (blockNodeSchemas.length === 0) { this.logger.debug('No block nodes found in remote config configuration'); return ''; } if (sameClusterBlockNodeSchemas.length === 0) { this.logger.info(`Skipping block node integration for mirror node cluster ${config.clusterReference}; no block node in the same cluster`); return ''; } let shouldConfigureMirrorNodeToPullFromBlockNode; if (config.forceBlockNodeIntegration) { // Bypass following checks this.logger.warn('Force flag enabled, bypassing version checks for block node integration'); shouldConfigureMirrorNodeToPullFromBlockNode = true; } else { const isConsensusNodeVersionSupported = this.remoteConfig.configuration.versions.consensusNode.greaterThanOrEqual(versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_TSS); const isBlockNodeChartVersionSupported = this.remoteConfig.configuration.versions.blockNodeChart.greaterThanOrEqual(versions.MINIMUM_BLOCK_NODE_CHART_VERSION_FOR_MIRROR_NODE_INTEGRATION); const isMirrorNodeVersionSupported = new SemanticVersion(config.mirrorNodeVersion).greaterThanOrEqual(versions.MINIMUM_MIRROR_NODE_CHART_VERSION_FOR_MIRROR_NODE_INTEGRATION); shouldConfigureMirrorNodeToPullFromBlockNode = isConsensusNodeVersionSupported && isBlockNodeChartVersionSupported && isMirrorNodeVersionSupported; } if (!shouldConfigureMirrorNodeToPullFromBlockNode) { this.logger.info('Mirror node will remain configured to pull from consensus node because version requirements were not met'); return ''; } const clusterSchemas = configuration.clusters; this.logger.debug('Preparing mirror node values args overrides for block nodes integration'); const blockNodeFqdnList = []; for (const blockNode of sameClusterBlockNodeSchemas) { const id = blockNode.metadata.id; const clusterReference = blockNode.metadata.cluster; const cluster = clusterSchemas.find((cluster) => cluster.name === clusterReference); if (!cluster) { throw new SoloError(`Cluster ${clusterReference} not found in remote config`); } const serviceName = Templates.renderBlockNodeName(id); const namespace = blockNode.metadata.namespace; const dnsBaseDomain = cluster.dnsBaseDomain; const fqdn = Templates.renderSvcFullyQualifiedDomainName(serviceName, namespace, dnsBaseDomain); blockNodeFqdnList.push({ host: fqdn, port: constants.BLOCK_NODE_PORT, }); } const data = { SPRING_PROFILES_ACTIVE: 'blocknode', }; for (const [index, node] of blockNodeFqdnList.entries()) { data[`HIERO_MIRROR_IMPORTER_BLOCK_NODES_${index}_HOST`] = node.host; if (node.port !== constants.BLOCK_NODE_PORT) { data[`HIERO_MIRROR_IMPORTER_BLOCK_NODES_${index}_PORT`] = node.port; } } const mirrorNodeBlockNodeValues = { importer: { env: data, }, }; const mirrorNodeBlockNodeValuesYaml = yaml.stringify(mirrorNodeBlockNodeValues); const valuesFilePath = PathEx.join(config.cacheDir, 'mirror-bn-values.yaml'); fs.writeFileSync(valuesFilePath, mirrorNodeBlockNodeValuesYaml); return ` --values ${valuesFilePath}`; } async prepareValuesArg(config) { let valuesArgument = ''; valuesArgument += ' --install'; if (config.valuesFile) { valuesArgument += helpers.prepareValuesFiles(config.valuesFile); } config.mirrorNodeVersion = SemanticVersion.getValidSemanticVersion(config.mirrorNodeVersion, true, 'Mirror node version'); const chartNamespace = MirrorNodeCommand_1.MIRROR_CHART_NAMESPACE; const environmentVariablePrefix = MirrorNodeCommand_1.MIRROR_ENVIRONMENT_VARIABLE_PREFIX; if (config.componentImage) { const parsedImageReference = ImageReference.parseImageReference(config.componentImage); valuesArgument += helpers.populateHelmArguments({ 'importer.image.registry': parsedImageReference.registry, 'grpc.image.registry': parsedImageReference.registry, 'rest.image.registry': parsedImageReference.registry, 'restjava.image.registry': parsedImageReference.registry, 'web3.image.registry': parsedImageReference.registry, 'monitor.image.registry': parsedImageReference.registry, 'importer.image.repository': parsedImageReference.repository, 'grpc.image.repository': parsedImageReference.repository, 'rest.image.repository': parsedImageReference.repository, 'restjava.image.repository': parsedImageReference.repository, 'web3.image.repository': parsedImageReference.repository, 'monitor.image.repository': parsedImageReference.repository, 'importer.image.tag': parsedImageReference.tag, 'grpc.image.tag': parsedImageReference.tag, 'rest.image.tag': parsedImageReference.tag, 'restjava.image.tag': parsedImageReference.tag, 'web3.image.tag': parsedImageReference.tag, 'monitor.image.tag': parsedImageReference.tag, }); } if (config.storageBucket) { valuesArgument += ` --set importer.config.${chartNamespace}.mirror.importer.downloader.bucketName=${config.storageBucket}`; } if (config.storageBucketPrefix) { this.logger.info(`Setting storage bucket prefix to ${config.storageBucketPrefix}`); valuesArgument += ` --set importer.config.${chartNamespace}.mirror.importer.downloader.pathPrefix=${config.storageBucketPrefix}`; } let storageType = ''; if (config.storageType !== constants.StorageType.MINIO_ONLY && config.storageReadAccessKey && config.storageReadSecrets && 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}`); } const mapping = { [`importer.env.${environmentVariablePrefix}_MIRROR_IMPORTER_DOWNLOADER_CLOUDPROVIDER`]: storageType, [`importer.env.${environmentVariablePrefix}_MIRROR_IMPORTER_DOWNLOADER_ENDPOINTOVERRIDE`]: config.storageEndpoint, [`importer.env.${environmentVariablePrefix}_MIRROR_IMPORTER_DOWNLOADER_ACCESSKEY`]: config.storageReadAccessKey, [`importer.env.${environmentVariablePrefix}_MIRROR_IMPORTER_DOWNLOADER_SECRETKEY`]: config.storageReadSecrets, }; valuesArgument += helpers.populateHelmArguments(mapping); } if (config.storageBucketRegion) { valuesArgument += ` --set importer.env.${environmentVariablePrefix}_MIRROR_IMPORTER_DOWNLOADER_REGION=${config.storageBucketRegion}`; } if (config.domainName) { valuesArgument += helpers.populateHelmArguments({ 'ingress.enabled': true, 'ingress.tls.enabled': false, 'ingress.hosts[0].host': config.domainName, }); } // if the useExternalDatabase populate all the required values before installing the chart let host, ownerPassword, ownerUsername, readonlyPassword, readonlyUsername; valuesArgument += helpers.populateHelmArguments({ // Disable default database deployment 'stackgres.enabled': false, 'postgresql.enabled': false, 'db.name': 'mirror_node', }); if (config.useExternalDatabase) { host = config.externalDatabaseHost; ownerPassword = config.externalDatabaseOwnerPassword; ownerUsername = config.externalDatabaseOwnerUsername; readonlyUsername = config.externalDatabaseReadonlyUsername; readonlyPassword = config.externalDatabaseReadonlyPassword; valuesArgument += helpers.populateHelmArguments({ // Set the host and name 'db.host': host, // 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, }); } else { valuesArgument += helpers.populateHelmArguments({ 'db.host': `solo-shared-resources-postgres.${config.namespace.name}.svc.cluster.local`, }); } valuesArgument += this.prepareBlockNodeIntegrationValues(config); return valuesArgument; } async deployMirrorNode({ config }, commandType) { // Determine if we should reuse values based on the currently deployed version from remote config // If upgrading from a version <= MIRROR_NODE_VERSION_BOUNDARY, we need to skip reuseValues // to avoid RegularExpression rules from old version causing relay node request failures const currentVersion = this.remoteConfig.getComponentVersion(ComponentTypes.MirrorNode); let shouldReuseValues = currentVersion ? currentVersion.greaterThan(constants.MIRROR_NODE_VERSION_BOUNDARY) : false; // If no current version (first install), don't reuse values // Don't reuse values when crossing the shared-resources/memory-improvements boundary // (upgrading from < v0.152.0 → >= v0.152.0). Versions before this boundary used an // embedded chart-managed Redis with sentinel nodes pointed at "<release>-redis". // Reusing those old values would leak the stale "SPRING_DATA_REDIS_SENTINEL_NODES" // configuration into the upgraded pods even though redis.enabled is now set to false, // because --reuse-values merges ALL old chart values (including sentinel node addresses) // and we only explicitly override redis.enabled / redis.host / redis.port — not every // sentinel sub-key. Forcing a clean value set here prevents pods from failing to // resolve the no-longer-existent "<release>-redis" hostname. if (shouldReuseValues && currentVersion !== null && currentVersion.lessThan(versions.MEMORY_ENHANCEMENTS_MIRROR_NODE_VERSION) && new SemanticVersion(config.mirrorNodeVersion).greaterThanOrEqual(versions.MEMORY_ENHANCEMENTS_MIRROR_NODE_VERSION)) { shouldReuseValues = false; } if (commandType === MirrorNodeCommandType.ADD) { shouldReuseValues = false; } await this.chartManager.upgrade(config.namespace, config.releaseName, constants.MIRROR_NODE_CHART, config.mirrorNodeChartDirectory || constants.MIRROR_NODE_RELEASE_NAME, config.mirrorNodeVersion, config.valuesArg, config.clusterContext, shouldReuseValues); this.eventBus.emit(new MirrorNodeDeployedEvent(config.deployment)); showVersionBanner(this.logger, constants.MIRROR_NODE_RELEASE_NAME, config.mirrorNodeVersion); if (commandType === MirrorNodeCommandType.ADD) { this.remoteConfig.configuration.components.changeComponentPhase(config.newMirrorNodeComponent.metadata.id, ComponentTypes.MirrorNode, DeploymentPhase.DEPLOYED); await this.remoteConfig.persist(); } else if (commandType === MirrorNodeCommandType.UPGRADE) { // update mirror node version in remote config after successful upgrade this.remoteConfig.updateComponentVersion(ComponentTypes.MirrorNode, new SemanticVersion(config.mirrorNodeVersion)); await this.remoteConfig.persist(); } if (config.enableIngress) { const existingIngressClasses = await this.k8Factory .getK8(config.clusterContext) .ingressClasses() .list(); for (const ingressClass of existingIngressClasses) { this.logger.debug(`Found existing IngressClass [${ingressClass.name}]`); if (ingressClass.name === constants.MIRROR_INGRESS_CLASS_NAME) { this.logger.showUser(`${constants.MIRROR_INGRESS_CLASS_NAME} already found, skipping`); return; } } await KeyManager.createTlsSecret(this.k8Factory, config.namespace, config.domainName, config.cacheDir, constants.MIRROR_INGRESS_TLS_SECRET_NAME); // patch ingressClassName of mirror ingress, so it can be recognized by haproxy ingress controller const updated = { metadata: { annotations: { 'haproxy-ingress.github.io/path-type': 'regex', }, }, spec: { ingressClassName: `${constants.MIRROR_INGRESS_CLASS_NAME}`, tls: [ { hosts: [config.domainName || 'localhost'], secretName: constants.MIRROR_INGRESS_TLS_SECRET_NAME, }, ], }, }; await this.k8Factory .getK8(config.clusterContext) .ingresses() .update(config.namespace, constants.MIRROR_NODE_RELEASE_NAME, updated); await this.k8Factory .getK8(config.clusterContext) .ingressClasses() .create(constants.MIRROR_INGRESS_CLASS_NAME, constants.INGRESS_CONTROLLER_PREFIX + constants.MIRROR_INGRESS_CONTROLLER); } } getReleaseName() { return this.renderReleaseName(this.remoteConfig.configuration.components.getNewComponentId(ComponentTypes.MirrorNode)); } getIngressReleaseName() { return this.renderIngressReleaseName(this.remoteConfig.configuration.components.getNewComponentId(ComponentTypes.MirrorNode)); } renderReleaseName(id) { if (typeof id !== 'number') { throw new SoloError(`Invalid component id: ${id}, type: ${typeof id}`); } return `${constants.MIRROR_NODE_RELEASE_NAME}-${id}`; } renderIngressReleaseName(id) { if (typeof id !== 'number') { throw new SoloError(`Invalid component id: ${id}, type: ${typeof id}`); } return `${constants.INGRESS_CONTROLLER_RELEASE_NAME}-${id}`; } enableSharedResourcesTask() { return { title: 'Enable shared resources', task: async (_, task) => { const subTasks = [ { title: 'Install Shared Resources chart', task: async (context_) => { if (!context_.config.useExternalDatabase) { this.sharedResourceManager.enablePostgres(); } this.sharedResourceManager.enableRedis(); context_.config.installSharedResources = await this.sharedResourceManager.installChart(context_.config.namespace, context_.config.chartDirectory, context_.config.soloChartVersion, context_.config.clusterContext, { 'redis.image.registry': constants.REDIS_IMAGE_REGISTRY, 'redis.image.repository': constants.REDIS_IMAGE_REPOSITORY, 'redis.image.tag': versions.REDIS_IMAGE_VERSION, 'redis.sentinel.image.registry': constants.REDIS_SENTINEL_IMAGE_REGISTRY, 'redis.sentinel.image.repository': constants.REDIS_SENTINEL_IMAGE_REPOSITORY, 'redis.sentinel.image.tag': versions.REDIS_SENTINEL_IMAGE_VERSION, 'redis.sentinel.masterSet': constants.REDIS_SENTINEL_MASTER_SET, }); }, }, { title: 'Load redis credentials', task: async (context_) => { const secrets = await this.k8Factory .getK8(context_.config.clusterContext) .secrets() .list(context_.config.namespace, ['app.kubernetes.io/instance=solo-shared-resources']); const secret = secrets.find((secret) => secret.name === 'solo-shared-resources-redis'); // Update values context_.config.valuesArg += helpers.populateHelmArguments({ 'redis.enabled': false, 'redis.auth.password': Base64.decode(secret.data['SPRING_DATA_REDIS_PASSWORD']), 'redis.host': Base64.decode(secret.data['SPRING_DATA_REDIS_HOST']), 'redis.port': Base64.decode(secret.data['SPRING_DATA_REDIS_PORT']), }); }, }, { title: 'Initialize Postgres pod', task: (_context_, task) => { const subTasks = [ { title: 'Wait for Postgres pod to be ready', task: async (context_) => { await this.postgresSharedResource.waitForPodReady(context_.config.namespace, context_.config.clusterContext); }, }, ]; // set up the sub-tasks return task.newListr(subTasks, { concurrent: false, // no need to run concurrently since if one node is up, the rest should be up by then rendererOptions: { collapseSubtasks: false, }, }); }, skip: (context_) => context_.config.useExternalDatabase, }, { title: 'Add shared resource components to remote config', skip: (context_) => !context_.config.installSharedResources || !this.remoteConfig.isLoaded(), task: async (context_) => { if (!context_.config.useExternalDatabase) { const postgresComponent = this.componentFactory.createNewPostgresComponent(context_.config.clusterReference, context_.config.namespace); this.remoteConfig.configuration.components.addNewComponent(postgresComponent, ComponentTypes.Postgres); } const redisComponent = this.componentFactory.createNewRedisComponent(context_.config.clusterReference, context_.config.namespace); this.remoteConfig.configuration.components.addNewComponent(redisComponent, ComponentTypes.Redis); await this.remoteConfig.persist(); }, }, ]; // set up the sub-tasks return task.newListr(subTasks, { concurrent: false, // no need to run concurrently since if one node is up, the rest should be up by then rendererOptions: { collapseSubtasks: false, }, }); }, }; } initializeSharedPostgresDatabaseTask() { return { title: 'Run database initialization script', task: async (context_) => { await this.postgresSharedResource.initializeMirrorNode(context_.config.namespace, context_.config.clusterContext, MirrorNodeCommand_1.MIRROR_ENVIRONMENT_VARIABLE_PREFIX); }, skip: ({ config }) => config.useExternalDatabase || !config.installSharedResources, }; } /** * Installs the mirror chart with all application components disabled in order to create the * `mirror-passwords` secret. The init script (run by {@link initializeSharedPostgresDatabaseTask}) * reads that secret to obtain the DB user passwords, so the secret must exist before init runs. * The importer must not be running during init (it would hold a session that blocks DROP DATABASE), * so we use this lightweight prime install instead of a full chart install. * * Skipped when the secret already exists (upgrade path) or when using an external database. */ /** * Deletes the `<release>-redis` secret so that the subsequent mirror chart install/upgrade * re-creates it cleanly. This is necessary because Kubernetes strategic-merge-patch does not * remove keys — stale `SPRING_DATA_REDIS_SENTINEL_NODES` values written by a previous install * (using the internal chart-managed Redis) would otherwise persist and cause pods to try to * resolve a non-existent hostname. */ deleteStaleRedisSecretTask() { return { title: 'Delete stale mirror redis secret', task: async (context_) => { // secrets().delete() returns true for NotFound, so no try/catch needed. await this.k8Factory .getK8(context_.config.clusterContext) .secrets() .delete(context_.config.namespace, `${context_.config.releaseName}-redis`); }, }; } primePostgresSecretTask() { return { title: 'Prime mirror-node postgres secret', task: async (context_) => { // Skip if the secret was already created by a previous install. const secretExists = await this.k8Factory .getK8(context_.config.clusterContext) .secrets() .exists(context_.config.namespace, 'mirror-passwords'); if (secretExists) { return; } // Install the mirror chart with every application component disabled. This is enough for // Helm to render and apply the `mirror-passwords` Secret template without starting any pods // that could connect to Postgres before the init script runs. // // redis.enabled must be false here: when true the chart writes SPRING_DATA_REDIS_SENTINEL_NODES // into the <release>-redis secret using the chart default host ({{ .Release.Name }}-redis). // Kubernetes strategic-merge-patch does not remove keys, so those stale sentinel values would // persist through the full upgrade (which sets redis.enabled=false and skips the sentinel block). // Setting redis.enabled=false in the prime install prevents the stale keys from ever being written. const primeValuesArgument = ' --install' + helpers.populateHelmArguments({ 'stackgres.enabled': false, 'postgresql.enabled': false, 'redis.enabled': false, 'db.host': `solo-shared-resources-postgres.${context_.config.namespace.name}.svc.cluster.local`, 'db.name': 'mirror_node', 'importer.enabled': false, 'grpc.enabled': false, 'rest.enabled': false, 'restjava.enabled': false, 'web3.enabled': false, 'rosetta.enabled': false, 'graphql.enabled': false, 'monitor.enabled': false, }); await this.chartManager.upgrade(context_.config.namespace, context_.config.releaseName, constants.MIRROR_NODE_CHART, context_.config.mirrorNodeChartDirectory || constants.MIRROR_NODE_RELEASE_NAME, context_.config.mirrorNodeVersion, primeValuesArgument, context_.config.clusterContext, false); }, skip: ({ config }) => config.useExternalDatabase || !config.installSharedResources, }; } enableMirrorNodeTask(commandType) { return { title: 'Enable mirror-node', task: (_, parentTask) => parentTask.newListr([ { title: 'Prepare address book', task: async (context_) => { if (this.oneShotState.isActive()) { context_.addressBook = await this.accountManager.buildAddressBookBase64(PathEx.join(context_.config.cacheDir, 'keys'), context_.config.deployment); context_.config.valuesArg += ` --set "importer.addressBook=${context_.addressBook}"`; } else { const deployment = this.configManager.getFlag(flags.deployment); const portForward = this.configManager.getFlag(flags.forcePortForward); context_.addressBook = await this.accountManager.prepareAddressBookBase64(context_.config.namespace, this.remoteConfig.getClusterRefs(), deployment, this.configManager.getFlag(flags.operatorId), this.configManager.getFlag(flags.operatorKey), portForward); context_.config.valuesArg += ` --set "importer.addressBook=${context_.addressBook}"`; } }, }, { title: 'Install mirror ingress controller', task: async (context_) => { const config = context_.config; let mirrorIngressControllerValuesArgument = ' --install '; mirrorIngressControllerValuesArgument += helpers.prepareValuesFiles(constants.INGRESS_CONTROLLER_VALUES_FILE); if (config.mirrorStaticIp !== '') { mirrorIngressControllerValuesArgument += ` --set controller.service.loadBalancerIP=${context_.config.mirrorStaticIp}`; } mirrorIngressControllerValuesArgument += ` --set fullnameOverride=${constants.MIRROR_INGRESS_CONTROLLER}-${config.namespace.name}`; mirrorIngressControllerValuesArgument += ` --set controller.ingressClass=${constants.MIRROR_INGRESS_CLASS_NAME}`; mirrorIngressControllerValuesArgument += ` --set controller.extraArgs.controller-class=${constants.MIRROR_INGRESS_CONTROLLER}`; mirrorIngressControllerValuesArgument += prepareValuesFiles(config.ingressControllerValueFile); await this.chartManager.upgrade(config.namespace, config.ingressReleaseName, constants.INGRESS_CONTROLLER_RELEASE_NAME, constants.INGRESS_CONTROLLER_RELEASE_NAME, INGRESS_CONTROLLER_VERSION, mirrorIngressControllerValuesArgument, context_.config.clusterContext); await this.adoptMirrorIngressControllerRbacOwnership(config); showVersionBanner(this.logger, config.ingressReleaseName, INGRESS_CONTROLLER_VERSION); }, skip: (context_) => !context_.config.enableIngress, }, { title: 'Deploy mirror-node', task: async (context_) => { await this.deployMirrorNode(context_, commandType); }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT), }; } checkPodsAreReadyNodeTask() { return { title: 'Check pods are ready', task: async (context_, task) => { const instanceCandidates = [ this.renderReleaseName(context_.config.id), // e.g. mirror-1 context_.config.releaseName, ]; if (context_.config.id === 1) { instanceCandidates.push(constants.MIRROR_NODE_RELEASE_NAME); // legacy release name } const podsInAllNamespaces = []; for (const instanceName of new Set(instanceCandidates)) { const candidatePods = await this.k8Factory .getK8(context_.config.clusterContext) .pods() .listForAllNamespaces([`app.kubernetes.io/instance=${instanceName}`]); podsInAllNamespaces.push(...candidatePods); } const podsClient = this.k8Factory.getK8(context_.config.clusterContext).pods(); const namespacePodReferences = [ ...new Map(podsInAllNamespaces .filter((pod) => pod.podReference?.namespace?.name === context_.config.namespace.name) .map((pod) => [ `${pod.podReference.namespace.name}/${pod.podReference.name.name}`, pod.podReference, ])).values(), ]; const namespacePods = await Promise.all(namespacePodReferences.map(async (podReference) => await podsClient.read(podReference))); const deployedPods = namespacePods.filter((pod) => !!pod.labels?.['app.kubernetes.io/component'] && !!pod.labels?.['app.kubernetes.io/name']); if (deployedPods.length === 0) { throw new SoloError(`No deployed mirror-node pods found for release ${context_.config.releaseName} in namespace ${context_.config.namespace.name}`); } const checksBySelector = new Map(); for (const pod of deployedPods) { const component = pod.labels?.['app.kubernetes.io/component']; const name = pod.labels?.['app.kubernetes.io/name']; const key = `${component}|${name}`; if (!checksBySelector.has(key)) { const titleName = component .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); checksBySelector.set(key, { title: `Check ${titleName}`, labels: [ `app.kubernetes.io/component=${component}`, `app.kubernetes.io/name=${name}`, `app.kubernetes.io/instance=${pod.labels?.['app.kubernetes.io/instance']}`, ], }); } } const subTasks = [ ...checksBySelector.values(), ].map(({ title, labels, }) => ({ title, task: async () => await this.k8Factory .getK8(context_.config.clusterContext) .pods() .waitForReadyStatus(context_.config.namespace, labels, constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY), })); return task.newListr(subTasks, constants.LISTR_DEFAULT_OPTIONS.WITH_CONCURRENCY); }, }; } enablePortForwardingTask() { return { title: 'Enable port forwarding for mirror ingress controller', skip: ({ config }) => !config.forcePortForward || !config.enableIngress, task: async ({ config }) => { const externalAddress = this.configManager.getFlag(flags.externalAddress); const pods = await this.k8Factory .getK8(config.clusterContext) .pods() .list(config.namespace, [`app.kubernetes.io/instance=${config.ingressReleaseName}`]); if (pods.length === 0) { throw new SoloError('No mirror ingress controller pod found'); } let podReference; for (const pod of pods) { if (pod?.podReference?.name?.name?.startsWith('mirror-ingress')) { podReference = pod.podReference; break; } } await this.remoteConfig.configuration.components.managePortForward(config.clusterReference, podReference, 80, // Pod port constants.MIRROR_NODE_PORT, // Local port this.k8Factory.getK8(config.clusterContext), this.logger, ComponentTypes.MirrorNode, 'Mirror ingress controller', config.isChartInstalled, // Reuse existing port if chart is already installed undefined, true, // persist: auto-restart on failure using persist-port-forward.js externalAddress); await this.remoteConfig.persist(); }, }; } async add(argv) { let lease; const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); await this.loadRemoteConfigOrWarn(argv); if (!this.oneShotState.isActive()) { lease = await this.leaseManager.create(); } this.configManager.update(argv); flags.disablePrompts(MirrorNodeCommand_1.DEPLOY_FLAGS_LIST.optional); const allFlags = [ ...MirrorNodeCommand_1.DEPLOY_FLAGS_LIST.required, ...MirrorNodeCommand_1.DEPLOY_FLAGS_LIST.optional, ]; await this.configManager.executePrompt(task, allFlags); const config = this.configManager.getConfig(MirrorNodeCommand_1.DEPLOY_CONFIGS_NAME, allFlags, []); context_.config = config; const hasMirrorNodeMemoryImprovements = new SemanticVersion(config.mirrorNodeVersion).greaterThanOrEqual(versions.MEMORY_ENHANCEMENTS_MIRROR_NODE_VERSION); config.namespace = await this.getNamespace(task); config.clusterReference = this.getClusterReference(); config.clusterContext = this.getClusterContext(config.clusterReference); config.newMirrorNodeComponent = this.componentFactory.createNewMirrorNodeComponent(config.clusterReference, config.namespace); config.newMirrorNodeComponent.metadata.phase = DeploymentPhase.REQUESTED; config.id = config.newMirrorNodeComponent.metadata.id; config.installSharedResources = false; const useMirrorNodeLegacyReleaseName = process.env.USE_MIRROR_NODE_LEGACY_RELEASE_NAME === 'true'; if (useMirrorNodeLegacyReleaseName) { config.releaseName = constants.MIRROR_NODE_RELEASE_NAME; config.ingressReleaseName = `${constants.INGRESS_CONTROLLER_RELEASE_NAME}-${config.namespace.name}`; } else { config.releaseName = this.getReleaseName(); config.ingressReleaseName = this.getIngressReleaseName(); } config.isChartInstalled = await this.chartManager.isChartInstalled(config.namespace, config.releaseName, config.clusterContext); context_.config.soloChartVersion = SemanticVersion.getValidSemanticVersion(context_.config.soloChartVersion, false, 'Solo chart version'); // predefined values first config.valuesArg = helpers.prepareValuesFiles(constants.MIRROR_NODE_VALUES_FILE); // user defined values later to override predefined values config.valuesArg += await this.prepareValuesArg(config); config.deployment = this.configManager.getFlag(flags.deployment); const realm = this.localConfig.configuration.realmForDeployment(config.deployment); const shard = this.localConfig.configuration.shardForDeployment(config.deployment); const chartNamespace = MirrorNodeCommand_1.MIRROR_CHART_NAMESPACE; const modules = ['monitor', 'rest', 'grpc', 'importer', 'restjava', 'graphql', 'rosetta', 'web3']; for (const module of modules) { config.valuesArg += ` --set ${module}.config.${chartNamespace}.mirror.common.realm=${realm}`; config.valuesArg += ` --set ${module}.config.${chartNamespace}.mirror.common.shard=${shard}`; } if (config.pinger) { if (!hasMirrorNodeMemoryImprovements) { config.valuesArg += ' --set pinger.enabled=false'; config.valuesArg += ' --set monitor.enabled=true'; config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.publish.scenarios.pinger.tps=${constants.MIRROR_NODE_PINGER_TPS}`; } const operatorId = config.operatorId || this.accountManager.getOperatorAccountId(config.deployment).toString(); const pingerRecipientAccountId = helpers.entityId(shard, realm, 98); config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.operator.accountId=${operatorId}`; config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.publish.scenarios.pinger.properties.senderAccountId=${operatorId}`; config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.publish.scenarios.pinger.properties.recipientAccountId=${pingerRecipientAccountId}`; config.valuesArg += ` --set pinger.env.HIERO_MIRROR_PINGER_OPERATOR_ID=${operatorId}`; config.valuesArg += ` --set pinger.env.HIERO_MIRROR_PINGER_TO_ACCOUNT_ID=${pingerRecipientAccountId}`; if (config.operatorKey) { this.logger.info('Using provided operator key'); config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.operator.privateKey=${config.operatorKey}`; config.valuesArg += ` --set pinger.env.HIERO_MIRROR_PINGER_OPERATOR_KEY=${config.operatorKey}`; } else { try { const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task); const secrets = await this.k8Factory .getK8(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`); config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.operator.privateKey=${constants.OPERATOR_KEY}`; config.valuesArg += ` --set pinger.env.HIERO_MIRROR_PINGER_OPERATOR_KEY=${constants.OPERATOR_KEY}`; } else { this.logger.info('Using operator key from k8s secret'); const operatorKeyFromK8 = Base64.decode(secrets[0].data.privateKey); config.valuesArg += ` --set monitor.config.${chartNamespace}.mirror.monitor.operator.privateKey=${operatorKeyFromK8}`; config.valuesArg += ` --set pinger.env.HIERO_MIRROR_PINGER_OPERATOR_KEY=${operatorKeyFromK8}`; } } catch (error) { throw new SoloError(`Error getting operator key: ${error.message}`, error); } } } else { context_.config.valuesArg += ' --set monitor.enabled=false'; co