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