@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
835 lines • 81.8 kB
JavaScript
// 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