UNPKG

@hashgraph/solo

Version:

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

937 lines 50.2 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 BlockNodeCommand_1; import { SoloError } from '../core/errors/solo-error.js'; import * as helpers from '../core/helpers.js'; import { checkDockerImageExists, createAndCopyBlockNodeJsonFileForConsensusNode, showVersionBanner, sleep, } from '../core/helpers.js'; import * as constants from '../core/constants.js'; import { BaseCommand } from './base.js'; import { Flags as flags } from './flags.js'; import { ListrLock } from '../core/lock/listr-lock.js'; import * as versions from '../../version.js'; import { MINIMUM_HIERO_BLOCK_NODE_VERSION_FOR_NEW_LIVENESS_CHECK_PORT } from '../../version.js'; import { ContainerReference } from '../integration/kube/resources/container/container-reference.js'; import { Duration } from '../core/time/duration.js'; import chalk from 'chalk'; import { ComponentTypes } from '../core/config/remote/enumerations/component-types.js'; import { injectable } from 'tsyringe-neo'; import { Templates } from '../core/templates.js'; import { SemanticVersion } from '../business/utils/semantic-version.js'; import { assertUpgradeVersionNotOlder } from '../core/upgrade-version-guard.js'; import { PvcReference } from '../integration/kube/resources/pvc/pvc-reference.js'; import { PvcName } from '../integration/kube/resources/pvc/pvc-name.js'; import { LedgerPhase } from '../data/schema/model/remote/ledger-phase.js'; import { ExternalBlockNodeStateSchema } from '../data/schema/model/remote/state/external-block-node-state-schema.js'; import { DeploymentPhase } from '../data/schema/model/remote/deployment-phase.js'; import { ComponentUpgradeMigrationRules, } from './migrations/component-upgrade-rules.js'; import { optionFromFlag } from './command-helpers.js'; let BlockNodeCommand = class BlockNodeCommand extends BaseCommand { static { BlockNodeCommand_1 = this; } constructor() { super(); } static ADD_CONFIGS_NAME = 'addConfigs'; static DESTROY_CONFIGS_NAME = 'destroyConfigs'; static UPGRADE_CONFIGS_NAME = 'upgradeConfigs'; static ADD_EXTERNAL_CONFIGS_NAME = 'addExternalConfigs'; static DELETE_CONFIGS_NAME = 'deleteExternalConfigs'; static MIGRATION_COMPONENT_KEY = 'block-node'; static ADD_FLAGS_LIST = { required: [flags.deployment], optional: [ flags.blockNodeChartVersion, flags.blockNodeChartDirectory, flags.blockNodeTssOverlay, flags.chartDirectory, flags.clusterRef, flags.devMode, flags.domainName, flags.enableIngress, flags.quiet, flags.valuesFile, flags.releaseTag, flags.imageTag, flags.priorityMapping, ], }; static ADD_EXTERNAL_FLAGS_LIST = { required: [flags.deployment, flags.externalBlockNodeAddress], optional: [flags.clusterRef, flags.devMode, flags.quiet, flags.priorityMapping], }; static DELETE_EXTERNAL_FLAGS_LIST = { required: [flags.deployment], optional: [flags.clusterRef, flags.devMode, flags.force, flags.quiet, flags.id], }; static DESTROY_FLAGS_LIST = { required: [flags.deployment], optional: [flags.chartDirectory, flags.clusterRef, flags.devMode, flags.force, flags.quiet, flags.id], }; static UPGRADE_FLAGS_LIST = { required: [flags.deployment], optional: [ flags.chartDirectory, flags.blockNodeChartDirectory, flags.clusterRef, flags.devMode, flags.force, flags.quiet, flags.valuesFile, flags.upgradeVersion, flags.id, ], }; async prepareValuesArgForBlockNode(config) { let valuesArgument = ''; valuesArgument += helpers.prepareValuesFiles(constants.BLOCK_NODE_VALUES_FILE); // Block node can be deployed before consensus deploy persists tssEnabled into remote config. // The explicit CLI switch allows users to opt into TSS sizing and message limits in that order-of-operations. if (this.remoteConfig.configuration.state.tssEnabled || config.blockNodeTssOverlay) { valuesArgument += helpers.prepareValuesFiles(constants.BLOCK_NODE_TSS_VALUES_FILE); } if (config.valuesFile) { valuesArgument += helpers.prepareValuesFiles(config.valuesFile); } valuesArgument += helpers.populateHelmArguments({ nameOverride: config.releaseName }); // Only handle domainName and imageTag for deploy config (not upgrade config) if ('domainName' in config && config.domainName) { valuesArgument += helpers.populateHelmArguments({ 'ingress.enabled': true, 'ingress.hosts[0].host': config.domainName, 'ingress.hosts[0].paths[0].path': '/', 'ingress.hosts[0].paths[0].pathType': 'ImplementationSpecific', }); } if ('imageTag' in config && config.imageTag) { config.imageTag = SemanticVersion.getValidSemanticVersion(config.imageTag, false, 'Block node image tag'); if (!checkDockerImageExists(constants.BLOCK_NODE_IMAGE_NAME, config.imageTag)) { throw new SoloError(`Local block node image with tag "${config.imageTag}" does not exist.`); } // use local image from docker engine valuesArgument += helpers.populateHelmArguments({ 'image.repository': constants.BLOCK_NODE_IMAGE_NAME, 'image.tag': config.imageTag, 'image.pullPolicy': 'Never', }); } const { state, clusters } = this.remoteConfig.configuration; for (const [index, blockNode] of state.blockNodes.entries()) { const cluster = clusters.find(({ name }) => name === blockNode.metadata.cluster); const fqdn = Templates.renderSvcFullyQualifiedDomainName(`block-node-${blockNode.metadata.id}`, config.namespace.name, cluster.dnsBaseDomain); valuesArgument += helpers.populateHelmArguments({ [`blockNode.sources[${index}].address`]: fqdn, [`blockNode.sources[${index}].port`]: constants.BLOCK_NODE_PORT, [`blockNode.sources[${index}].priority`]: 1, }); } return valuesArgument; } static appendExtraCommandArgs(baseArgument, extraCommandArguments) { if (extraCommandArguments.length === 0) { return baseArgument; } return `${baseArgument} ${extraCommandArguments.join(' ')}`.trim(); } getReleaseName() { return this.renderReleaseName(this.remoteConfig.configuration.components.getNewComponentId(ComponentTypes.BlockNode)); } renderReleaseName(id) { if (typeof id !== 'number') { throw new SoloError(`Invalid component id: ${id}, type: ${typeof id}`); } return `${constants.BLOCK_NODE_RELEASE_NAME}-${id}`; } updateConsensusNodesInRemoteConfig() { return { title: 'Update consensus nodes in remote config', task: async ({ config: { newBlockNodeComponent, priorityMapping } }) => { const state = this.remoteConfig.configuration.state; const nodeAliases = Object.keys(priorityMapping); for (const node of state.consensusNodes.filter((node) => nodeAliases.includes(Templates.renderNodeAliasFromNumber(node.metadata.id)))) { const priority = priorityMapping[Templates.renderNodeAliasFromNumber(node.metadata.id)]; node.blockNodeMap.push([newBlockNodeComponent.metadata.id, priority]); } await this.remoteConfig.persist(); }, }; } updateConsensusNodesPostGenesis() { return { title: 'Copy block-nodes.json to consensus nodes', task: async ({ config: { priorityMapping } }) => { const nodeAliases = Object.keys(priorityMapping); const filteredConsensusNodes = this.remoteConfig .getConsensusNodes() .filter((node) => nodeAliases.includes(node.name)); for (const node of filteredConsensusNodes) { await createAndCopyBlockNodeJsonFileForConsensusNode(node, this.logger, this.k8Factory); } }, }; } updateConsensusNodesPostGenesisForExternal() { return { title: 'Copy block-nodes.json to consensus nodes', task: async ({ config: { priorityMapping } }) => { const nodeAliases = Object.keys(priorityMapping); const filteredConsensusNodes = this.remoteConfig .getConsensusNodes() .filter((node) => nodeAliases.includes(node.name)); for (const node of filteredConsensusNodes) { await createAndCopyBlockNodeJsonFileForConsensusNode(node, this.logger, this.k8Factory); } }, }; } handleConsensusNodeUpdating() { return { title: 'Update consensus nodes', task: (_, task) => { const subTasks = [this.updateConsensusNodesInRemoteConfig()]; if (this.remoteConfig.configuration.state.ledgerPhase !== LedgerPhase.UNINITIALIZED) { subTasks.push(this.updateConsensusNodesPostGenesis()); } return task.newListr(subTasks, constants.LISTR_DEFAULT_OPTIONS.DEFAULT); }, }; } updateConsensusNodesInRemoteConfigForExternalBlockNode() { return { title: 'Update consensus nodes in remote config', task: async ({ config: { newExternalBlockNodeComponent, priorityMapping } }) => { const state = this.remoteConfig.configuration.state; const nodeAliases = Object.keys(priorityMapping); for (const node of state.consensusNodes.filter((node) => nodeAliases.includes(Templates.renderNodeAliasFromNumber(node.metadata.id)))) { const priority = priorityMapping[Templates.renderNodeAliasFromNumber(node.metadata.id)]; node.externalBlockNodeMap.push([newExternalBlockNodeComponent.id, priority]); } this.remoteConfig.configuration.state.consensusNodes = state.consensusNodes; await this.remoteConfig.persist(); }, }; } handleConsensusNodeUpdatingForExternalBlockNode() { return { title: 'Update consensus nodes', task: (_, task) => { const subTasks = [ this.updateConsensusNodesInRemoteConfigForExternalBlockNode(), ]; if (this.remoteConfig.configuration.state.ledgerPhase !== LedgerPhase.UNINITIALIZED) { subTasks.push(this.updateConsensusNodesPostGenesisForExternal()); } return task.newListr(subTasks, constants.LISTR_DEFAULT_OPTIONS.DEFAULT); }, }; } 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(BlockNodeCommand_1.ADD_FLAGS_LIST.optional); const allFlags = [ ...BlockNodeCommand_1.ADD_FLAGS_LIST.required, ...BlockNodeCommand_1.ADD_FLAGS_LIST.optional, ]; await this.configManager.executePrompt(task, allFlags); const config = this.configManager.getConfig(BlockNodeCommand_1.ADD_CONFIGS_NAME, allFlags); context_.config = config; // check if block node version compatible with current hedera platform version let consensusNodeVersion = this.remoteConfig.configuration.versions.consensusNode.toString(); if (consensusNodeVersion === '0.0.0') { // if is possible block node deployed before consensus node, then use release tag as fallback consensusNodeVersion = config.releaseTag; } const currentVersion = new SemanticVersion(consensusNodeVersion); const minimumVersion = new SemanticVersion(versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_BLOCK_NODE); if (currentVersion.lessThan(minimumVersion)) { throw new SoloError(`Current version is ${consensusNodeVersion}, Hedera platform versions less than ${versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_BLOCK_NODE_LEGACY_RELEASE} are not supported`); } config.namespace = await this.getNamespace(task); config.clusterRef = this.getClusterReference(); config.context = this.getClusterContext(config.clusterRef); config.priorityMapping = Templates.parseBlockNodePriorityMapping(config.priorityMapping, this.remoteConfig.getConsensusNodes()); const currentBlockNodeVersion = new SemanticVersion(config.chartVersion); const consensusNodeSemanticVersion = new SemanticVersion(consensusNodeVersion); if (consensusNodeSemanticVersion.lessThan(new SemanticVersion(versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_BLOCK_NODE)) && currentBlockNodeVersion.greaterThanOrEqual(MINIMUM_HIERO_BLOCK_NODE_VERSION_FOR_NEW_LIVENESS_CHECK_PORT)) { throw new SoloError(`Current platform version is ${consensusNodeVersion}, Hedera platform version less than ${versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_BLOCK_NODE} ` + `are not supported for block node version ${MINIMUM_HIERO_BLOCK_NODE_VERSION_FOR_NEW_LIVENESS_CHECK_PORT.toString()}`); } config.chartVersion = SemanticVersion.getValidSemanticVersion(config.chartVersion, false, 'Block node chart version'); config.livenessCheckPort = this.getLivenessCheckPortNumber(config.chartVersion, config.imageTag); if (!this.oneShotState.isActive()) { return ListrLock.newAcquireLockTask(lease, task); } return ListrLock.newSkippedLockTask(task); }, }, { title: 'Prepare release name and block node name', task: async ({ config }) => { config.releaseName = this.getReleaseName(); config.newBlockNodeComponent = this.componentFactory.createNewBlockNodeComponent(config.clusterRef, config.namespace); config.newBlockNodeComponent.metadata.phase = DeploymentPhase.REQUESTED; }, }, this.addBlockNodeComponent(), { title: 'Prepare chart values', task: async ({ config }) => { config.valuesArg = await this.prepareValuesArgForBlockNode(config); }, }, { title: 'Deploy block node', task: async ({ config }, task) => { const { context, namespace, releaseName, chartVersion, valuesArg, clusterRef, imageTag, blockNodeChartDirectory, newBlockNodeComponent, } = config; await this.chartManager.install(namespace, releaseName, constants.BLOCK_NODE_CHART, blockNodeChartDirectory || constants.BLOCK_NODE_CHART_URL, chartVersion, valuesArg, context); this.remoteConfig.configuration.components.changeComponentPhase(newBlockNodeComponent.metadata.id, ComponentTypes.BlockNode, DeploymentPhase.DEPLOYED); await this.remoteConfig.persist(); if (imageTag) { // update config map with new VERSION info since // it will be used as a critical environment variable by block node const blockNodeStateSchema = this.componentFactory.createNewBlockNodeComponent(clusterRef, namespace); const blockNodeId = blockNodeStateSchema.metadata.id; const name = `block-node-${blockNodeId}-config`; const data = { VERSION: imageTag }; await this.k8Factory.getK8(context).configMaps().update(namespace, name, data); task.title += ` with local built image (${imageTag})`; } showVersionBanner(this.logger, releaseName, chartVersion); await this.updateBlockNodeVersionInRemoteConfig(config); }, }, { title: 'Check block node pod is running', task: async ({ config }) => { await this.k8Factory .getK8(config.context) .pods() .waitForRunningPhase(config.namespace, Templates.renderBlockNodeLabels(config.newBlockNodeComponent.metadata.id), constants.BLOCK_NODE_PODS_RUNNING_MAX_ATTEMPTS, constants.BLOCK_NODE_PODS_RUNNING_DELAY); }, }, { title: 'Check software', task: async ({ config: { newBlockNodeComponent, context, namespace } }) => { const labels = Templates.renderBlockNodeLabels(newBlockNodeComponent.metadata.id); const blockNodePods = await this.k8Factory.getK8(context).pods().list(namespace, labels); if (blockNodePods.length === 0) { throw new SoloError('Failed to list block node pod'); } }, }, { title: 'Check block node pod is ready', task: async ({ config }) => { try { await this.k8Factory .getK8(config.context) .pods() .waitForReadyStatus(config.namespace, Templates.renderBlockNodeLabels(config.newBlockNodeComponent.metadata.id), constants.BLOCK_NODE_PODS_RUNNING_MAX_ATTEMPTS, constants.BLOCK_NODE_PODS_RUNNING_DELAY); } catch (error) { throw new SoloError(`Block node ${config.releaseName} is not ready: ${error.message}`, error); } }, }, this.checkBlockNodeReadiness(), this.handleConsensusNodeUpdating(), ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'block node add'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError(`Error deploying block node: ${error.message}`, error); } finally { if (!this.oneShotState.isActive()) { await lease?.release(); } } } else { this.taskList.registerCloseFunction(async () => { if (!this.oneShotState.isActive()) { await lease?.release(); } }); } return true; } async destroy(argv) { let lease; const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); if (!this.oneShotState.isActive()) { lease = await this.leaseManager.create(); } this.configManager.update(argv); flags.disablePrompts(BlockNodeCommand_1.DESTROY_FLAGS_LIST.optional); const allFlags = [ ...BlockNodeCommand_1.DESTROY_FLAGS_LIST.required, ...BlockNodeCommand_1.DESTROY_FLAGS_LIST.optional, ]; await this.configManager.executePrompt(task, allFlags); const config = this.configManager.getConfig(BlockNodeCommand_1.DESTROY_CONFIGS_NAME, allFlags); context_.config = config; config.namespace = await this.getNamespace(task); config.clusterRef = this.getClusterReference(); config.context = this.getClusterContext(config.clusterRef); const { id, releaseName, isChartInstalled, isLegacyChartInstalled } = await this.inferDestroyData(config.id, config.namespace, config.context); config.id = id; config.releaseName = releaseName; config.isChartInstalled = isChartInstalled; config.isLegacyChartInstalled = isLegacyChartInstalled; await this.throwIfNamespaceIsMissing(config.context, config.namespace); if (!this.oneShotState.isActive()) { return ListrLock.newAcquireLockTask(lease, task); } return ListrLock.newSkippedLockTask(task); }, }, { title: 'Destroy block node', task: async ({ config: { namespace, releaseName, context } }) => { await this.chartManager.uninstall(namespace, releaseName, context); const podReferences = await this.k8Factory .getK8(context) .pvcs() .list(namespace, [`app.kubernetes.io/instance=${releaseName}`]) .then((pvcs) => pvcs.map((pvc) => PvcName.of(pvc))) .then((names) => names.map((pvc) => PvcReference.of(namespace, pvc))); for (const podReference of podReferences) { await this.k8Factory.getK8(context).pvcs().delete(podReference); } }, skip: ({ config }) => !config.isChartInstalled, }, this.removeBlockNodeComponentFromRemoteConfig(), this.rebuildBlockNodesJsonForConsensusNodes(), ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'block node destroy'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError(`Error destroying block node: ${error.message}`, error); } finally { if (!this.oneShotState.isActive()) { await lease?.release(); } } } else { this.taskList.registerCloseFunction(async () => { if (!this.oneShotState.isActive()) { await lease?.release(); } }); } return true; } async upgrade(argv) { let lease; const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); if (!this.oneShotState.isActive()) { lease = await this.leaseManager.create(); } this.configManager.update(argv); flags.disablePrompts(BlockNodeCommand_1.UPGRADE_FLAGS_LIST.optional); const allFlags = [ ...BlockNodeCommand_1.UPGRADE_FLAGS_LIST.required, ...BlockNodeCommand_1.UPGRADE_FLAGS_LIST.optional, ]; await this.configManager.executePrompt(task, allFlags); const config = this.configManager.getConfig(BlockNodeCommand_1.UPGRADE_CONFIGS_NAME, allFlags); context_.config = config; config.namespace = await this.getNamespace(task); config.clusterRef = this.getClusterReference(); config.context = this.getClusterContext(config.clusterRef); config.id = this.inferBlockNodeId(config.id); config.isLegacyChartInstalled = await this.checkIfLegacyChartIsInstalled(config.id, config.namespace, config.context); config.releaseName = config.isLegacyChartInstalled ? `${constants.BLOCK_NODE_RELEASE_NAME}-0` : this.renderReleaseName(config.id); config.context = this.remoteConfig.getClusterRefs()[config.clusterRef]; config.upgradeVersion ||= versions.BLOCK_NODE_VERSION; config.currentVersion = this.remoteConfig.getComponentVersion(ComponentTypes.BlockNode)?.toString() ?? '0.0.0'; assertUpgradeVersionNotOlder('Block node', config.upgradeVersion, this.remoteConfig.getComponentVersion(ComponentTypes.BlockNode), optionFromFlag(flags.upgradeVersion)); if (!this.oneShotState.isActive()) { return ListrLock.newAcquireLockTask(lease, task); } return ListrLock.newSkippedLockTask(task); }, }, { title: 'Look-up block node', task: async ({ config }) => { try { this.remoteConfig.configuration.components.getComponent(ComponentTypes.BlockNode, config.id); } catch (error) { throw new SoloError(`Block node ${config.releaseName} was not found`, error); } }, }, { title: 'Prepare chart values', task: async ({ config }) => { config.valuesArg = await this.prepareValuesArgForBlockNode(config); }, }, { title: 'Plan block node upgrade migration', task: async ({ config }, task) => { config.migrationPlan = this.buildBlockNodeUpgradeMigrationPlan(config.currentVersion, config.upgradeVersion); const renderedPlan = config.migrationPlan .map((step) => `${step.fromVersion} -> ${step.toVersion} [${step.strategy}] (${step.reason})`) .join(' | '); task.title = `${task.title}: ${renderedPlan}`; }, }, { title: 'Update block node chart', task: async ({ config }) => { const { namespace, releaseName, context } = config; for (const step of config.migrationPlan) { const stepTargetVersion = SemanticVersion.getValidSemanticVersion(step.toVersion, false, 'Block node chart version'); const stepValuesArgument = BlockNodeCommand_1.appendExtraCommandArgs(config.valuesArg, step.extraCommandArgs); if (step.strategy === 'recreate') { this.logger.showUser(`Applying block node recreate migration for ${releaseName} (${step.fromVersion} -> ${stepTargetVersion}): ${step.reason}`); await this.recreateBlockNodeChart(config, stepTargetVersion, step); } else { try { await this.chartManager.upgrade(namespace, releaseName, constants.BLOCK_NODE_CHART, config.blockNodeChartDirectory || constants.BLOCK_NODE_CHART_URL, stepTargetVersion, stepValuesArgument, context); } catch (error) { if (this.isImmutableStatefulSetError(error)) { this.logger.showUser(`Detected immutable StatefulSet upgrade for ${releaseName}; retrying with recreate migration`); await this.recreateBlockNodeChart(config, stepTargetVersion, step); } else { throw error; } } } // Persist the applied step version so remote config reflects the last // successfully applied step even if a later step fails. this.remoteConfig.updateComponentVersion(ComponentTypes.BlockNode, new SemanticVersion(stepTargetVersion)); await this.remoteConfig.persist(); } showVersionBanner(this.logger, constants.BLOCK_NODE_CHART, config.upgradeVersion); await this.updateBlockNodeVersionInRemoteConfig(config); }, }, { title: 'Check block node pod is ready', task: async ({ config }) => { try { await this.k8Factory .getK8(config.context) .pods() .waitForReadyStatus(config.namespace, Templates.renderBlockNodeLabels(config.id), constants.BLOCK_NODE_PODS_RUNNING_MAX_ATTEMPTS, constants.BLOCK_NODE_PODS_RUNNING_DELAY, config.recreateInstallTime); } catch (error) { throw new SoloError(`Block node ${config.releaseName} is not ready after upgrade: ${error.message}`, error); } }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'block node upgrade'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError(`Error upgrading block node: ${error.message}`, error); } finally { if (!this.oneShotState.isActive()) { await lease?.release(); } } } else { this.taskList.registerCloseFunction(async () => { if (!this.oneShotState.isActive()) { await lease?.release(); } }); } return true; } async addExternal(argv) { let lease; const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); if (!this.oneShotState.isActive()) { lease = await this.leaseManager.create(); } this.configManager.update(argv); flags.disablePrompts(BlockNodeCommand_1.ADD_EXTERNAL_FLAGS_LIST.optional); const allFlags = [ ...BlockNodeCommand_1.ADD_EXTERNAL_FLAGS_LIST.required, ...BlockNodeCommand_1.ADD_EXTERNAL_FLAGS_LIST.optional, ]; await this.configManager.executePrompt(task, allFlags); const config = this.configManager.getConfig(BlockNodeCommand_1.ADD_EXTERNAL_CONFIGS_NAME, allFlags); context_.config = config; config.clusterRef = this.getClusterReference(); config.context = this.getClusterContext(config.clusterRef); config.namespace = await this.getNamespace(task); config.priorityMapping = Templates.parseBlockNodePriorityMapping(config.priorityMapping, this.remoteConfig.getConsensusNodes()); const id = this.remoteConfig.configuration.state.externalBlockNodes.length + 1; const [address, port] = Templates.parseExternalBlockAddress(config.externalBlockNodeAddress); config.newExternalBlockNodeComponent = new ExternalBlockNodeStateSchema(id, address, port); this.logger.showUser('Configuring external block node, ' + `${chalk.grey('ID')} ${chalk.cyan(`[${id}]`)}, ` + `${chalk.grey('address')} ${chalk.cyan(`[${address}:${port}]`)} `); if (!this.oneShotState.isActive()) { return ListrLock.newAcquireLockTask(lease, task); } return ListrLock.newSkippedLockTask(task); }, }, this.addExternalBlockNodeComponent(), this.handleConsensusNodeUpdatingForExternalBlockNode(), ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'block node add-external'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError(`Error adding external block node: ${error.message}`, error); } finally { if (!this.oneShotState.isActive()) { await lease?.release(); } } } else { this.taskList.registerCloseFunction(async () => { if (!this.oneShotState.isActive()) { await lease?.release(); } }); } return true; } async deleteExternal(argv) { let lease; const tasks = this.taskList.newTaskList([ { title: 'Initialize', task: async (context_, task) => { await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); if (!this.oneShotState.isActive()) { lease = await this.leaseManager.create(); } this.configManager.update(argv); flags.disablePrompts(BlockNodeCommand_1.DELETE_EXTERNAL_FLAGS_LIST.optional); const allFlags = [ ...BlockNodeCommand_1.DELETE_EXTERNAL_FLAGS_LIST.required, ...BlockNodeCommand_1.DELETE_EXTERNAL_FLAGS_LIST.optional, ]; await this.configManager.executePrompt(task, allFlags); const config = this.configManager.getConfig(BlockNodeCommand_1.DELETE_CONFIGS_NAME, allFlags); context_.config = config; config.namespace = await this.getNamespace(task); config.clusterRef = this.getClusterReference(); config.context = this.getClusterContext(config.clusterRef); config.id = this.inferExternalBlockNodeId(config.id); await this.throwIfNamespaceIsMissing(config.context, config.namespace); if (!this.oneShotState.isActive()) { return ListrLock.newAcquireLockTask(lease, task); } return ListrLock.newSkippedLockTask(task); }, }, this.removeExternalBlockNodeComponent(), this.rebuildBlockNodesJsonForConsensusNodes(), ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, undefined, 'block node delete-external'); if (tasks.isRoot()) { try { await tasks.run(); } catch (error) { throw new SoloError(`Error removing external block node: ${error.message}`, error); } finally { if (!this.oneShotState.isActive()) { await lease?.release(); } } } else { this.taskList.registerCloseFunction(async () => { if (!this.oneShotState.isActive()) { await lease?.release(); } }); } return true; } rebuildBlockNodesJsonForConsensusNodes() { return { title: "Rebuild 'block.nodes.json' for consensus nodes", skip: () => this.remoteConfig.configuration.state.ledgerPhase === LedgerPhase.UNINITIALIZED, task: async () => { for (const node of this.remoteConfig.getConsensusNodes()) { await createAndCopyBlockNodeJsonFileForConsensusNode(node, this.logger, this.k8Factory); } }, }; } /** * Gives the port used for liveness check based on the chart version and image tag (if set) */ getLivenessCheckPortNumber(chartVersion, imageTag) { let useLegacyPort = false; chartVersion = typeof chartVersion === 'string' ? new SemanticVersion(chartVersion) : chartVersion; imageTag = typeof imageTag === 'string' && imageTag ? new SemanticVersion(imageTag) : undefined; if (chartVersion.lessThan(versions.MINIMUM_HIERO_BLOCK_NODE_VERSION_FOR_NEW_LIVENESS_CHECK_PORT)) { useLegacyPort = true; } else if (imageTag && imageTag.lessThan(versions.MINIMUM_HIERO_BLOCK_NODE_VERSION_FOR_NEW_LIVENESS_CHECK_PORT)) { useLegacyPort = true; } return useLegacyPort ? constants.BLOCK_NODE_PORT_LEGACY : constants.BLOCK_NODE_PORT; } async updateBlockNodeVersionInRemoteConfig(config) { let blockNodeVersion; let imageTag; if (config.hasOwnProperty('upgradeVersion') && config.upgradeVersion) { const version = config.upgradeVersion; blockNodeVersion = typeof version === 'string' ? new SemanticVersion(version) : version; } if (config.hasOwnProperty('chartVersion') && config.chartVersion) { const version = config.chartVersion; blockNodeVersion = typeof version === 'string' ? new SemanticVersion(version) : version; } if (config.hasOwnProperty('imageTag') && config.imageTag) { const tag = config.imageTag; imageTag = typeof tag === 'string' ? new SemanticVersion(tag) : tag; } const finalVersion = imageTag && blockNodeVersion.lessThan(imageTag) ? imageTag : blockNodeVersion; this.remoteConfig.updateComponentVersion(ComponentTypes.BlockNode, finalVersion); await this.remoteConfig.persist(); } buildBlockNodeUpgradeMigrationPlan(currentVersion, targetVersion) { const normalizedCurrentVersion = SemanticVersion.getValidSemanticVersion(currentVersion || '0.0.0', false, 'Current block node chart version'); const normalizedTargetVersion = SemanticVersion.getValidSemanticVersion(targetVersion || versions.BLOCK_NODE_VERSION, false, 'Target block node chart version'); return ComponentUpgradeMigrationRules.planUpgradeMigrationPath(BlockNodeCommand_1.MIGRATION_COMPONENT_KEY, normalizedCurrentVersion, normalizedTargetVersion); } isImmutableStatefulSetError(error) { const message = error instanceof Error ? error.message : String(error); return message.includes('StatefulSet.apps') && message.includes('spec: Forbidden'); } async recreateBlockNodeChart(config, validatedUpgradeVersion, step) { const valuesArgument = BlockNodeCommand_1.appendExtraCommandArgs(config.valuesArg, step.extraCommandArgs); await this.chartManager.uninstall(config.namespace, config.releaseName, config.context); // Wait for the old pod to be fully terminated before creating the new StatefulSet. // helm uninstall returns immediately (no --wait), but the pod has a graceful shutdown period. // The new StatefulSet will not create a replacement pod until the old pod with the same // ordinal name is completely gone (StatefulSet at-most-one semantics), and the PVC cannot // be reattached while the old pod still holds a ReadWriteOnce volume mount. await this.waitForBlockNodePodsDeleted(config.namespace, config.id, config.context); // Record the install time so the readiness check can ignore any stale pod references. config.recreateInstallTime = new Date(); await this.chartManager.install(config.namespace, config.releaseName, constants.BLOCK_NODE_CHART, config.blockNodeChartDirectory || constants.BLOCK_NODE_CHART_URL, validatedUpgradeVersion, valuesArgument, config.context); } /** * Polls until no pods with the block-node label exist in the namespace. * Used before re-installing the chart so the new StatefulSet pod is not blocked * by a terminating predecessor. */ async waitForBlockNodePodsDeleted(namespace, id, context) { const labels = Templates.renderBlockNodeLabels(id); const maxAttempts = constants.BLOCK_NODE_PODS_RUNNING_MAX_ATTEMPTS; const delay = constants.BLOCK_NODE_PODS_RUNNING_DELAY; for (let attempt = 0; attempt < maxAttempts; attempt++) { const pods = await this.k8Factory.getK8(context).pods().list(namespace, labels); if (pods.length === 0) { return; } await new Promise((resolve) => setTimeout(resolve, delay)); } this.logger.warn(`Block node pods with labels ${labels.join(',')} did not terminate within ${maxAttempts} attempts; proceeding with install`); } /** Adds the block node component to remote config. */ addBlockNodeComponent() { return { title: 'Add block node component in remote config', skip: () => !this.remoteConfig.isLoaded() || this.oneShotState.isActive(), task: async ({ config }) => { this.remoteConfig.configuration.components.addNewComponent(config.newBlockNodeComponent, ComponentTypes.BlockNode); await this.remoteConfig.persist(); }, }; } /** Adds the block node component to remote config. */ addExternalBlockNodeComponent() { return { title: 'Add external block node component in remote config', skip: () => !this.remoteConfig.isLoaded(), task: async ({ config: { newExternalBlockNodeComponent } }) => { this.remoteConfig.configuration.state.externalBlockNodes.push(newExternalBlockNodeComponent); await this.remoteConfig.persist(); }, }; } /** Adds the block node component to remote config. */ removeBlockNodeComponentFromRemoteConfig() { return { title: 'Disable block node component in remote config', skip: () => !this.remoteConfig.isLoaded(), task: async ({ config }) => { this.remoteConfig.configuration.components.removeComponent(config.id, ComponentTypes.BlockNode); for (const node of this.remoteConfig.configuration.state.consensusNodes) { node.blockNodeMap = node.blockNodeMap.filter(([id]) => id !== config.id); } await this.remoteConfig.persist(); }, }; } /** Adds the block node component to remote config. */ removeExternalBlockNodeComponent() { return { title: 'Remove block node component from remote config', skip: () => !this.remoteConfig.isLoaded(), task: async ({ config }) => { this.remoteConfig.configuration.state.externalBlockNodes = this.remoteConfig.configuration.state.externalBlockNodes.filter((component) => component.id !== config.id); for (const node of this.remoteConfig.configuration.state.consensusNodes) { node.externalBlockNodeMap = node.externalBlockNodeMap.filter(([id]) => id !== config.id); } await this.remoteConfig.persist(); }, }; } displayHealthCheckData(task) { const baseTitle = task.title; return function (attempt, maxAttempt, color = 'yellow', additionalData = '') { task.title = `${baseTitle} - ${chalk[color](`[${attempt}/${maxAttempt}]`)} ${chalk[color](additionalData)}`; }; } checkBlockNodeReadiness() { return { title: 'Check block node readiness', task: async ({ config }, task) => { const displayHealthcheckCallback = this.displayHealthCheckData(task); const blockNodePodReference = await this.k8Factory .getK8(config.context) .pods() .list(config.namespace, Templates.renderBlockNodeLabels(config.newBlockNodeComponent.metadata.id)) .then((pods) => pods[0].podReference); const containerReference = ContainerReference.of(blockNodePodReference, constants.BLOCK_NODE_CONTAINER_NAME); const maxAttempts = constants.BLOCK_NODE_ACTIVE_MAX_ATTEMPTS; let attempt = 1; let success = false; displayHealthcheckCallback(attempt, maxAttempts); while (attempt < maxAttempts) { try { const response = await helpers.withTimeout(this.k8Factory .getK8(config.context) .containers() .readByRef(containerReference) .execContainer(['bash', '-c', `curl -s http://localhost:${config.livenessCheckPort}/healthz/readyz`]), Duration.ofSeconds(constants.BLOCK_NODE_ACTIVE_TIMEOUT), 'Healthcheck timed out'); if (response !== 'OK') { throw new SoloError('Bad response status'); } success = true; break; } catch (error) { this.logger.debug(`Waiting for block node health check to come back with OK status: ${error.message}, [attempts: ${attempt}/${maxAttempts}`); } attempt++; await sleep(Duration.ofSeconds(constants.BLOCK_NODE_ACTIVE_DELAY)); displayHealthcheckCallback(attempt, maxAttempts); } if (!success) { displayHealthcheckCallback(attempt, maxAttempts, 'red', 'max attempts reached'); throw new SoloError('Max attempts reached'); } displayHealthcheckCallback(attempt, maxAttempts, 'green', 'success'); }, }; } async close() { } // no-op inferBlockNodeId(id) { if (typeof id === 'number') { return id; } if (this.remoteConfig.configuration.components.state.blockNodes.length === 0) { throw new SoloError('Block node not found in remote config.' + id ? `ID ${id}` : ''); } return this.remoteConfig.configuration.components.state.blockNodes[0].metadata.id; } inferExternalBlockNodeId(id) { if (typeof id === 'number') { return id; } if (this.remoteConfig.configuration.components.state.externalBlockNodes.length === 0) { throw new SoloError('No External block node not found in remote config. ' + id ? `ID ${id}` : ''); } return this.remoteConfig.configuration.components.state.externalBlockNodes[0].id; } async checkIfLegacyChartIsInstalled(id, namespace, context) { return id === 1 ? await this.chartManager.isChartInstalled(namespace, `${constants.BLOCK_NODE_RELEASE_NAME}-0`, context) : false; } async inferDestroyData(id, namespace, context) { id = this.inferBlockNodeId(id); const isLegacyChartInstalled = await this.checkIfLegacyChartIsInstalled(id, namespace, context); if (isLegacyChartInstalled) { return { id, releaseName: `${constants.BLOCK_NODE_RELEASE_NAME}-0`, isChartInstalled: true, isLegacyChartInstalled, }; } const releaseName = this.renderReleaseName(id); return { id, releaseName, isChartInstalled: await this.chartManager.isChartInstalled(namespace, releaseName, context), isLegacyChartInstalled, }; } }; BlockNodeCommand = BlockN