@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
1,029 lines (952 loc) • 88.5 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import {Listr, ListrRendererValue} from 'listr2';
import {SoloError} from '../../core/errors/solo-error.js';
import * as constants from '../../core/constants.js';
import {BaseCommand} from '../base.js';
import {Flags as flags, Flags} from '../flags.js';
import {
type AnyListrContext,
type AnyObject,
type ArgvStruct,
type NodeAlias,
type NodeId,
} from '../../types/aliases.js';
import {
type DeploymentName,
type Optional,
type Realm,
type Shard,
type SoloListr,
type SoloListrTask,
type SoloListrTaskWrapper,
} from '../../types/index.js';
import {type CommandFlag, type CommandFlags} from '../../types/flag-types.js';
import {inject, injectable} from 'tsyringe-neo';
import {NamespaceName} from '../../types/namespace/namespace-name.js';
import {StringEx} from '../../business/utils/string-ex.js';
import {OneShotCommand} from './one-shot.js';
import {OneShotSingleDeployConfigClass, OneShotVersionsObject} from './one-shot-single-deploy-config-class.js';
import {OneShotSingleDeployContext} from './one-shot-single-deploy-context.js';
import {OneShotSingleDestroyConfigClass} from './one-shot-single-destroy-config-class.js';
import * as version from '../../../version.js';
import {confirm as confirmPrompt, select as selectPrompt} from '@inquirer/prompts';
import {ClusterReferenceCommandDefinition} from '../command-definitions/cluster-reference-command-definition.js';
import {DeploymentCommandDefinition} from '../command-definitions/deployment-command-definition.js';
import {ConsensusCommandDefinition} from '../command-definitions/consensus-command-definition.js';
import {KeysCommandDefinition} from '../command-definitions/keys-command-definition.js';
import {MirrorCommandDefinition} from '../command-definitions/mirror-command-definition.js';
import {ExplorerCommandDefinition} from '../command-definitions/explorer-command-definition.js';
import {RelayCommandDefinition} from '../command-definitions/relay-command-definition.js';
import {patchInject} from '../../core/dependency-injection/container-helper.js';
import {InjectTokens} from '../../core/dependency-injection/inject-tokens.js';
import {type AccountManager} from '../../core/account-manager.js';
import {
CreatedPredefinedAccount,
PREDEFINED_ACCOUNT_GROUPS,
PredefinedAccount,
predefinedEcdsaAccountsWithAlias,
SystemAccount,
} from './predefined-accounts.js';
import {
AccountId,
Client,
HbarUnit,
PublicKey,
TopicCreateTransaction,
TopicId,
TopicInfoQuery,
} from '@hiero-ledger/sdk';
import * as helpers from '../../core/helpers.js';
import {createDirectoryIfNotExists, entityId, remoteConfigsToDeploymentsTable} from '../../core/helpers.js';
import {Duration} from '../../core/time/duration.js';
import {resolveNamespaceFromDeployment} from '../../core/resolvers.js';
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk';
import {PathEx} from '../../business/utils/path-ex.js';
import yaml from 'yaml';
import {BlockCommandDefinition} from '../command-definitions/block-command-definition.js';
import {argvPushGlobalFlags, invokeSoloCommand, newArgv, optionFromFlag} from '../command-helpers.js';
import {ConfigMap} from '../../integration/kube/resources/config-map/config-map.js';
import {type K8} from '../../integration/kube/k8.js';
import {Templates} from '../../core/templates.js';
import {ListrInquirerPromptAdapter} from '@listr2/prompt-adapter-inquirer';
import {SemanticVersion} from '../../business/utils/semantic-version.js';
import {type Lock} from '../../core/lock/lock.js';
import {ListrLock} from '../../core/lock/listr-lock.js';
import {ResourceNotFoundError} from '../../integration/kube/errors/resource-operation-errors.js';
import {NoKubeConfigContextError} from '../../business/runtime-state/errors/no-kube-config-context-error.js';
import {RelayNodeStateSchema} from '../../data/schema/model/remote/state/relay-node-state-schema.js';
import {DeploymentPhase} from '../../data/schema/model/remote/deployment-phase.js';
import {ComponentTypes} from '../../core/config/remote/enumerations/component-types.js';
import {MirrorNodeStateSchema} from '../../data/schema/model/remote/state/mirror-node-state-schema.js';
import {ExplorerStateSchema} from '../../data/schema/model/remote/state/explorer-state-schema.js';
import {BlockNodeStateSchema} from '../../data/schema/model/remote/state/block-node-state-schema.js';
import {type SoloEventBus} from '../../core/events/solo-event-bus.js';
import {SoloEventType} from '../../core/events/event-types/solo-event.js';
import {MirrorNodeDeployedEvent} from '../../core/events/event-types/mirror-node-deployed-event.js';
import {NodesStartedEvent} from '../../core/events/event-types/nodes-started-event.js';
import {DeploymentSchema} from '../../data/schema/model/local/deployment-schema.js';
import {Deployment} from '../../business/runtime-state/config/local/deployment.js';
import {MutableFacadeArray} from '../../business/runtime-state/collection/mutable-facade-array.js';
import {StringFacade} from '../../business/runtime-state/facade/string-facade.js';
import {DeploymentStateSchema} from '../../data/schema/model/remote/deployment-state-schema.js';
import {OneShotInfoContext} from './one-shot-info-context.js';
import {ApplicationVersionsSchema} from '../../data/schema/model/common/application-versions-schema.js';
import {CacheCommandDefinition} from '../command-definitions/cache-command-definition.js';
@injectable()
export class DefaultOneShotCommand extends BaseCommand implements OneShotCommand {
private static readonly SINGLE_DEPLOY_CONFIGS_NAME: string = 'singleAddConfigs';
private static readonly SINGLE_DESTROY_CONFIGS_NAME: string = 'singleDestroyConfigs';
private _isRollback: boolean = false;
public static readonly DEPLOY_FLAGS_LIST: CommandFlags = {
required: [],
optional: [
flags.quiet,
flags.force,
flags.deployment,
flags.namespace,
flags.clusterRef,
flags.minimalSetup,
flags.rollback,
flags.parallelDeploy,
flags.externalAddress,
flags.edgeEnabled,
],
};
public static readonly MULTI_DEPLOY_FLAGS_LIST: CommandFlags = {
required: [],
optional: [...DefaultOneShotCommand.DEPLOY_FLAGS_LIST.optional, flags.numberOfConsensusNodes],
};
public static readonly DESTROY_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.deployment],
};
public static readonly FALCON_DEPLOY_FLAGS_LIST: CommandFlags = {
required: [],
optional: [
flags.quiet,
flags.force,
flags.valuesFile,
flags.numberOfConsensusNodes,
flags.deployment,
flags.namespace,
flags.clusterRef,
flags.deployMirrorNode,
flags.deployExplorer,
flags.deployRelay,
flags.deployMetricsServer,
flags.rollback,
flags.parallelDeploy,
flags.externalAddress,
],
};
public static readonly FALCON_DESTROY_FLAGS_LIST: CommandFlags = {
required: [],
optional: [...DefaultOneShotCommand.DESTROY_FLAGS_LIST.optional],
};
public static readonly INFO_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.deployment],
};
public constructor(
@inject(InjectTokens.AccountManager) private readonly accountManager: AccountManager,
@inject(InjectTokens.SoloEventBus) private readonly eventBus: SoloEventBus,
) {
super();
this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name);
this.eventBus = patchInject(eventBus, InjectTokens.SoloEventBus, this.constructor.name);
}
/**
* Concatenates a default config file with an override file, writing the result to outputFilePath.
* Later entries in the file override earlier ones, so the override values take precedence.
*/
private concatConfigFiles(defaultFilePath: string, overrideFilePath: string, outputFilePath: string): string {
const defaultContent: string = fs.existsSync(defaultFilePath) ? fs.readFileSync(defaultFilePath, 'utf8') : '';
const overrideContent: string = fs.existsSync(overrideFilePath) ? fs.readFileSync(overrideFilePath, 'utf8') : '';
const outputDirectory: string = path.dirname(outputFilePath);
if (!fs.existsSync(outputDirectory)) {
fs.mkdirSync(outputDirectory, {recursive: true});
}
fs.writeFileSync(outputFilePath, defaultContent.trimEnd() + '\n' + overrideContent);
return outputFilePath;
}
/**
* Appends non-empty config entries to the argv array as CLI flags.
* @param argv - The argument array to append to
* @param configSection - The config object to extract key-value pairs from
*/
private appendConfigToArgv(argv: string[], configSection: AnyObject): void {
if (!configSection) {
return;
}
for (const [key, value] of Object.entries(configSection)) {
if (
value !== undefined &&
value !== null &&
value !== StringEx.EMPTY &&
key !== flags.getFormattedFlagKey(Flags.deployment)
) {
argv.push(`${key}`, value.toString());
}
}
}
public async deploy(argv: ArgvStruct): Promise<boolean> {
return this.deployInternal(argv, DefaultOneShotCommand.DEPLOY_FLAGS_LIST);
}
public async deployFalcon(argv: ArgvStruct): Promise<boolean> {
return this.deployInternal(argv, DefaultOneShotCommand.FALCON_DEPLOY_FLAGS_LIST);
}
private async performRollback(
deployError: Error,
config: OneShotSingleDeployConfigClass | undefined,
): Promise<never> {
if (!config) {
throw new SoloError(
`Deploy failed: ${deployError.message}. Rollback skipped: no resources created.`,
deployError,
);
}
if (config.rollback === false) {
this.logger.warn('Automatic rollback skipped (--no-rollback flag provided)');
this.logger.warn('To clean up: solo one-shot single destroy');
this.logger.warn(`Or: kubectl delete ns ${config.namespace.name}`);
throw new SoloError(`Deploy failed: ${deployError.message}. Rollback skipped (--no-rollback).`, deployError);
}
this.logger.warn(
`Deploy failed. Starting automatic rollback for deployment '${config.deployment}' in namespace '${config.namespace.name}'...`,
);
const destroyArgv: ArgvStruct = {
_: [],
deployment: config.deployment,
clusterRef: config.clusterRef,
namespace: config.namespace.name,
context: config.context,
quiet: true,
};
this._isRollback = true;
try {
await this.destroyInternal(destroyArgv, DefaultOneShotCommand.DESTROY_FLAGS_LIST);
} catch (rollbackError) {
this.logger.error(`Rollback failed for deployment '${config.deployment}': ${rollbackError.message}`);
throw new SoloError(
`Deploy failed: ${deployError.message}. Rollback also failed: ${rollbackError.message}`,
deployError,
);
} finally {
// Safety net: ensure namespace is always deleted during rollback, even if destroyInternal
// failed or skipped namespace cleanup (e.g. due to skipAll, helm uninstall failure, etc.)
try {
const k8: K8 = this.k8Factory.getK8(config.context);
if (await k8.namespaces().has(config.namespace)) {
this.logger.warn(`Rollback cleanup: deleting namespace '${config.namespace.name}'`);
await k8.namespaces().delete(config.namespace);
}
} catch (cleanupError) {
this.logger.warn(
`Failed to delete namespace '${config.namespace.name}' during rollback cleanup: ${cleanupError.message}`,
);
}
this._isRollback = false;
}
this.logger.info(`Rollback complete. Cache preserved at: ${config.cacheDir}`);
throw new SoloError(`Deploy failed: ${deployError.message}. Rollback completed successfully.`, deployError);
}
private async deployInternal(argv: ArgvStruct, flagsList: CommandFlags): Promise<boolean> {
let config: OneShotSingleDeployConfigClass | undefined = undefined;
let oneShotLease: Lock | undefined;
const mirrorNodeId: number = 1;
const tasks: Listr<OneShotSingleDeployContext, ListrRendererValue, ListrRendererValue> =
this.taskList.newOneShotSingleDeployTaskList(
[
{
title: 'Initialize',
task: async (
context_: OneShotSingleDeployContext,
task: SoloListrTaskWrapper<OneShotSingleDeployContext>,
): Promise<void> => {
this.configManager.update(argv);
this.oneShotState.activate();
const edgeEnabled: boolean = this.configManager.getFlag(Flags.edgeEnabled);
const versions: OneShotVersionsObject = this.resolveOneShotComponentVersions(edgeEnabled);
// Pre-set component version flags in configManager so they are available
// for all sub-commands during concurrent execution
this.configManager.setFlag(Flags.releaseTag, versions.consensus);
this.configManager.setFlag(Flags.blockNodeChartVersion, versions.blockNode);
this.configManager.setFlag(Flags.mirrorNodeVersion, versions.mirror);
this.configManager.setFlag(Flags.relayReleaseTag, versions.relay);
this.configManager.setFlag(Flags.explorerVersion, versions.explorer);
this.configManager.setFlag(Flags.soloChartVersion, versions.soloChart);
flags.disablePrompts(flagsList.optional);
const allFlags: CommandFlag[] = [...flagsList.required, ...flagsList.optional];
await this.configManager.executePrompt(task, allFlags);
context_.config = this.configManager.getConfig(
DefaultOneShotCommand.SINGLE_DEPLOY_CONFIGS_NAME,
allFlags,
) as OneShotSingleDeployConfigClass;
config = context_.config;
// Initialize component config sections to empty objects to prevent undefined errors
config.consensusNodeConfiguration = {};
config.mirrorNodeConfiguration = {};
config.blockNodeConfiguration = {};
config.explorerNodeConfiguration = {};
config.relayNodeConfiguration = {};
config.networkConfiguration = {};
config.setupConfiguration = {};
config.versions = versions;
config.cacheDir ??= constants.SOLO_CACHE_DIR;
// if valuesFile is set, read the yaml file and save flags to different config sections to be used
// later for consensus node, mirror node, block node, explorer node, relay node
if (config.valuesFile) {
const valuesFileContent: string = fs.readFileSync(context_.config.valuesFile, 'utf8');
const profileItems: Record<string, AnyObject> = yaml.parse(valuesFileContent) as Record<
string,
AnyObject
>;
// Override with values from file if they exist
if (profileItems.network) {
config.networkConfiguration = profileItems.network;
}
if (profileItems.setup) {
config.setupConfiguration = profileItems.setup;
}
if (profileItems.consensusNode) {
config.consensusNodeConfiguration = profileItems.consensusNode;
}
if (profileItems.mirrorNode) {
config.mirrorNodeConfiguration = profileItems.mirrorNode;
}
if (profileItems.blockNode) {
config.blockNodeConfiguration = profileItems.blockNode;
}
if (profileItems.explorerNode) {
config.explorerNodeConfiguration = profileItems.explorerNode;
}
if (profileItems.relayNode) {
config.relayNodeConfiguration = profileItems.relayNode;
}
}
config.clusterRef = config.clusterRef || 'one-shot';
config.context = config.context || this.k8Factory.default().contexts().readCurrent();
config.deployment = config.deployment || 'one-shot';
config.namespace = config.namespace || NamespaceName.of('one-shot');
this.configManager.setFlag(flags.namespace, config.namespace);
config.numberOfConsensusNodes = config.numberOfConsensusNodes || 1;
config.force = argv.force;
// Ensure release tag is set in network configuration so subcommands use the correct version
const releaseTagKey: string = flags.getFormattedFlagKey(Flags.releaseTag);
if (!config.networkConfiguration[releaseTagKey]) {
config.networkConfiguration[releaseTagKey] = versions.consensus;
}
if (!config.setupConfiguration[releaseTagKey]) {
config.setupConfiguration[releaseTagKey] = versions.consensus;
}
this.logger.addLogBindings({
clusterReference: config.clusterRef,
context: config.context,
deployment: config.deployment,
namespace: config.namespace.name,
});
// Apply small-memory node configuration only for CN >= 0.72.0 and when not using `one-shot falcon deploy`
const MINIMUM_CN_VERSION_FOR_SMALL_MEMORY: string = 'v0.72.0-0';
const MINIMUM_CN_VERSION_FOR_STATE_ON_DISK: string = 'v0.73.0-0';
const cnVersion: SemanticVersion<string> = new SemanticVersion(versions.consensus);
if (!config.valuesFile && cnVersion.greaterThanOrEqual(MINIMUM_CN_VERSION_FOR_SMALL_MEMORY)) {
const defaultsDirectory: string = PathEx.join(constants.SOLO_CACHE_DIR, 'templates');
const overridesDirectory: string = PathEx.join(defaultsDirectory, 'small-memory');
const stateOnDiskDirectory: string = PathEx.join(defaultsDirectory, 'small-memory-state-on-disk');
const mergedDirectory: string = PathEx.join(defaultsDirectory, 'small-memory-merged');
const settingsOverrideFile: string =
config.numberOfConsensusNodes > 1 ? 'settings-multinode.txt' : 'settings-single.txt';
const useStateOnDisk: boolean = cnVersion.greaterThanOrEqual(MINIMUM_CN_VERSION_FOR_STATE_ON_DISK);
const settingsMergedPath: string = PathEx.join(mergedDirectory, 'settings.txt');
// Merge default settings with small-memory overrides
this.concatConfigFiles(
PathEx.join(defaultsDirectory, 'settings.txt'),
PathEx.join(overridesDirectory, settingsOverrideFile),
settingsMergedPath,
);
// For CN >= 0.73.0, append state-on-disk settings
config.networkConfiguration[flags.getFormattedFlagKey(flags.settingTxt)] = useStateOnDisk
? this.concatConfigFiles(
settingsMergedPath,
PathEx.join(stateOnDiskDirectory, 'settings.txt'),
settingsMergedPath,
)
: settingsMergedPath;
config.networkConfiguration[flags.getFormattedFlagKey(flags.applicationProperties)] =
this.concatConfigFiles(
PathEx.join(defaultsDirectory, constants.APPLICATION_PROPERTIES),
PathEx.join(overridesDirectory, constants.APPLICATION_PROPERTIES),
PathEx.join(mergedDirectory, constants.APPLICATION_PROPERTIES),
);
// For CN >= 0.73.0, use state-on-disk application.env instead of default small-memory
config.networkConfiguration[flags.getFormattedFlagKey(flags.applicationEnv)] = useStateOnDisk
? PathEx.join(stateOnDiskDirectory, 'application.env')
: PathEx.join(overridesDirectory, 'application.env');
const throttlesFile: string = PathEx.join(overridesDirectory, 'throttles.json');
if (fs.existsSync(throttlesFile)) {
config.networkConfiguration[flags.getFormattedFlagKey(flags.genesisThrottlesFile)] = throttlesFile;
}
// For CN >= 0.73.0, cap K8s container memory at 1Gi to prevent unbounded mmap'd state-on-disk page cache growth
if (useStateOnDisk) {
const helmOverrideFile: string = PathEx.join(stateOnDiskDirectory, 'helm-overrides.yaml');
if (fs.existsSync(helmOverrideFile)) {
config.networkConfiguration[flags.getFormattedFlagKey(flags.valuesFile)] =
`${config.clusterRef}=${helmOverrideFile}`;
}
}
}
// Auto-enable PVCs in network configuration when --local-build-path is used in setup configuration.
// Node PVCs are required to persist custom JARs across pod restarts.
if (
config.setupConfiguration[flags.getFormattedFlagKey(flags.localBuildPath)] &&
!config.networkConfiguration[flags.getFormattedFlagKey(flags.persistentVolumeClaims)]
) {
this.logger.info(
'Auto-enabling PVCs in network configuration because --local-build-path is set in setup. ' +
'Node PVCs are required to persist custom JARs across pod restarts.',
);
config.networkConfiguration[flags.getFormattedFlagKey(flags.persistentVolumeClaims)] = 'true';
}
// Initialize deployment toggles with defaults if not specified
config.deployMirrorNode = config.deployMirrorNode === undefined ? true : config.deployMirrorNode;
config.deployExplorer = config.deployExplorer === undefined ? true : config.deployExplorer;
config.deployRelay = config.deployRelay === undefined ? true : config.deployRelay;
context_.createdAccounts = [];
this.logger.debug(`quiet: ${config.quiet}`);
return;
},
},
{
title: 'Acquire deployment lock',
task: async (
_: OneShotSingleDeployContext,
task: SoloListrTaskWrapper<OneShotSingleDeployContext>,
): Promise<Listr<OneShotSingleDeployContext>> => {
oneShotLease = await this.leaseManager.create();
return ListrLock.newAcquireLockTask(oneShotLease, task);
},
},
{
title: 'Check for other deployments',
task: async (
_: OneShotSingleDeployContext,
task: SoloListrTaskWrapper<OneShotSingleDeployContext>,
): Promise<void> => {
const existingRemoteConfigs: ConfigMap[] = await this.k8Factory
.default()
.configMaps()
.listForAllNamespaces(Templates.renderConfigMapRemoteConfigLabels());
if (existingRemoteConfigs.length > 0) {
const existingDeploymentsTable: string[] = remoteConfigsToDeploymentsTable(existingRemoteConfigs);
const promptOptions: {default: boolean; message: string} = {
default: false,
message:
'⚠️ Warning: Existing solo deployment detected in cluster.\n\n' +
existingDeploymentsTable.join('\n') +
'\n\nCreating another deployment will require additional' +
' CPU and memory resources. Do you want to proceed and create another deployment?',
};
const proceed: boolean = await task
.prompt(ListrInquirerPromptAdapter)
.run(confirmPrompt, promptOptions);
if (!proceed) {
throw new SoloError('Aborted by user');
}
}
},
skip: (context_: OneShotSingleDeployContext): boolean =>
context_.config.force === true || context_.config.quiet === true,
},
invokeSoloCommand(
`solo ${CacheCommandDefinition.IMAGE_PULL_COMMAND}`,
CacheCommandDefinition.IMAGE_PULL_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...CacheCommandDefinition.IMAGE_PULL_COMMAND.split(' '),
optionFromFlag(Flags.edgeEnabled),
(!!config.edgeEnabled).toString(),
);
return argvPushGlobalFlags(argv);
},
this.taskList,
(): boolean => !constants.CONFIG.ENABLE_IMAGE_CACHE,
),
invokeSoloCommand(
`solo ${CacheCommandDefinition.IMAGE_LOAD_COMMAND}`,
CacheCommandDefinition.IMAGE_LOAD_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...CacheCommandDefinition.IMAGE_LOAD_COMMAND.split(' '),
optionFromFlag(Flags.clusterRef),
config.clusterRef,
);
return argvPushGlobalFlags(argv);
},
this.taskList,
(): boolean => !constants.CONFIG.ENABLE_IMAGE_CACHE,
),
invokeSoloCommand(
`solo ${ClusterReferenceCommandDefinition.CONNECT_COMMAND}`,
ClusterReferenceCommandDefinition.CONNECT_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...ClusterReferenceCommandDefinition.CONNECT_COMMAND.split(' '),
optionFromFlag(Flags.clusterRef),
config.clusterRef,
optionFromFlag(Flags.context),
config.context,
);
return argvPushGlobalFlags(argv);
},
this.taskList,
),
invokeSoloCommand(
`solo ${DeploymentCommandDefinition.CREATE_COMMAND}`,
DeploymentCommandDefinition.CREATE_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...DeploymentCommandDefinition.CREATE_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
optionFromFlag(Flags.namespace),
config.namespace.name,
);
return argvPushGlobalFlags(argv);
},
this.taskList,
),
invokeSoloCommand(
`solo ${DeploymentCommandDefinition.ATTACH_COMMAND}`,
DeploymentCommandDefinition.ATTACH_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...DeploymentCommandDefinition.ATTACH_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
optionFromFlag(Flags.clusterRef),
config.clusterRef,
optionFromFlag(Flags.numberOfConsensusNodes),
config.numberOfConsensusNodes.toString(),
);
return argvPushGlobalFlags(argv);
},
this.taskList,
),
invokeSoloCommand(
`solo ${ClusterReferenceCommandDefinition.SETUP_COMMAND}`,
ClusterReferenceCommandDefinition.SETUP_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...ClusterReferenceCommandDefinition.SETUP_COMMAND.split(' '),
optionFromFlag(Flags.clusterRef),
config.clusterRef,
);
if (config.deployMetricsServer) {
argv.push(optionFromFlag(Flags.deployMetricsServer));
}
return argvPushGlobalFlags(argv);
},
this.taskList,
),
invokeSoloCommand(
`solo ${KeysCommandDefinition.KEYS_COMMAND}`,
KeysCommandDefinition.KEYS_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...KeysCommandDefinition.KEYS_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
optionFromFlag(Flags.generateGossipKeys),
'true',
optionFromFlag(Flags.generateTlsKeys),
);
return argvPushGlobalFlags(argv, config.cacheDir);
},
this.taskList,
),
{
title: 'Create remote config components',
task: async (): Promise<void> => {
// Pre add remote config components to remote config
if (constants.ONE_SHOT_WITH_BLOCK_NODE === 'true') {
// Add Block Node
const blockNode: BlockNodeStateSchema = this.componentFactory.createNewBlockNodeComponent(
config.clusterRef,
config.namespace,
);
blockNode.metadata.phase = DeploymentPhase.REQUESTED;
this.remoteConfig.configuration.components.addNewComponent(
blockNode,
ComponentTypes.BlockNode,
false,
true,
);
}
// Add Explorer
if (config.deployExplorer) {
const explorer: ExplorerStateSchema = this.componentFactory.createNewExplorerComponent(
config.clusterRef,
config.namespace,
);
explorer.metadata.phase = DeploymentPhase.REQUESTED;
this.remoteConfig.configuration.components.addNewComponent(
explorer,
ComponentTypes.Explorer,
false,
true,
);
}
// Add Mirror Node
if (config.deployMirrorNode) {
const mirrorNode: MirrorNodeStateSchema = this.componentFactory.createNewMirrorNodeComponent(
config.clusterRef,
config.namespace,
);
mirrorNode.metadata.phase = DeploymentPhase.REQUESTED;
this.remoteConfig.configuration.components.addNewComponent(
mirrorNode,
ComponentTypes.MirrorNode,
false,
true,
);
}
// Add Relay
if (config.deployRelay) {
const nodeIds: NodeId[] = [];
for (const alias of Templates.renderNodeAliasesFromCount(config.numberOfConsensusNodes, 0)) {
nodeIds.push(Templates.nodeIdFromNodeAlias(alias));
}
const relay: RelayNodeStateSchema = this.componentFactory.createNewRelayComponent(
config.clusterRef,
config.namespace,
nodeIds,
);
relay.metadata.phase = DeploymentPhase.REQUESTED;
this.remoteConfig.configuration.components.addNewComponent(
relay,
ComponentTypes.RelayNodes,
false,
true,
);
}
await this.remoteConfig.persist();
},
},
{
title: 'Deploy Solo components',
task: (_, task): SoloListr<OneShotSingleDeployContext> => {
// Network node pipeline: deploy network node, then setup, start consensus node, and account generation
// Must be sequential
const deployNetworkNodeTask: SoloListrTask<OneShotSingleDeployContext> = {
title: 'Deploy network node',
task: async (_, networkNodeTask): Promise<SoloListr<OneShotSingleDeployContext>> => {
return networkNodeTask.newListr(
[
invokeSoloCommand(
`solo ${ConsensusCommandDefinition.DEPLOY_COMMAND}`,
ConsensusCommandDefinition.DEPLOY_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...ConsensusCommandDefinition.DEPLOY_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
);
if (config.networkConfiguration) {
this.appendConfigToArgv(argv, config.networkConfiguration);
}
return argvPushGlobalFlags(argv, config.cacheDir);
},
this.taskList,
),
{
title: 'Setup and Start consensus node',
task: async (
_: OneShotSingleDeployContext,
task: SoloListrTaskWrapper<OneShotSingleDeployContext>,
): Promise<SoloListr<OneShotSingleDeployContext>> => {
return task.newListr(
[
invokeSoloCommand(
`solo ${ConsensusCommandDefinition.SETUP_COMMAND}`,
ConsensusCommandDefinition.SETUP_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...ConsensusCommandDefinition.SETUP_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
);
this.appendConfigToArgv(argv, config.setupConfiguration);
return argvPushGlobalFlags(argv, config.cacheDir);
},
this.taskList,
),
invokeSoloCommand(
`solo ${ConsensusCommandDefinition.START_COMMAND}`,
ConsensusCommandDefinition.START_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...ConsensusCommandDefinition.START_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
);
this.appendConfigToArgv(argv, {
[optionFromFlag(Flags.externalAddress)]: config.externalAddress,
...config.consensusNodeConfiguration,
});
return argvPushGlobalFlags(argv);
},
this.taskList,
),
{
title: 'Create Accounts',
skip: (): boolean => config.predefinedAccounts === false,
task: async (
_: OneShotSingleDeployContext,
task: SoloListrTaskWrapper<OneShotSingleDeployContext>,
): Promise<Listr<OneShotSingleDeployContext>> => {
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
const subTasks: SoloListrTask<OneShotSingleDeployContext>[] = [];
const client: Client = await this.accountManager.loadNodeClient(
config.namespace,
this.remoteConfig.getClusterRefs(),
config.deployment,
);
const realm: Realm = this.localConfig.configuration.realmForDeployment(
config.deployment,
);
const shard: Shard = this.localConfig.configuration.shardForDeployment(
config.deployment,
);
// Check if Topic with ID 1001 exists, if not create a buffer topic to bump the entity ID counter
// so that created accounts have IDs start from x.x.1002
try {
const entity1001Query: TopicInfoQuery = new TopicInfoQuery().setTopicId(
TopicId.fromString(entityId(realm, shard, 1001)),
);
await entity1001Query.execute(client);
} catch (error) {
try {
if (error.message.includes('INVALID_TOPIC_ID')) {
const bufferTopic: TopicCreateTransaction =
new TopicCreateTransaction().setTopicMemo('Buffer topic to bump entity IDs');
await bufferTopic.execute(client);
}
} catch (error) {
this.logger.warn(
'Failed to create topic. Created account IDs may be offset from the expected values.',
error,
);
}
}
const accountsToCreate: PredefinedAccount[] = [...predefinedEcdsaAccountsWithAlias];
for (const [index, account] of accountsToCreate.entries()) {
// inject index to avoid closure issues
((index: number, account: PredefinedAccount): void => {
subTasks.push({
title: `Creating Account ${index}`,
task: async (
context_: OneShotSingleDeployContext,
subTask: SoloListrTaskWrapper<OneShotSingleDeployContext>,
): Promise<void> => {
await helpers.sleep(Duration.ofMillis(100 * index));
const createdAccount: {
accountId: string;
privateKey: string;
publicKey: string;
balance: number;
accountAlias?: string;
} = await this.accountManager.createNewAccount(
context_.config.namespace,
account.privateKey,
account.balance.to(HbarUnit.Hbar).toNumber(),
account.alias,
context_.config.context,
);
context_.createdAccounts.push({
accountId: AccountId.fromString(createdAccount.accountId),
data: account,
alias: createdAccount.accountAlias,
publicKey: createdAccount.publicKey,
});
subTask.title = `Account created: ${createdAccount.accountId.toString()}`;
},
});
})(index, account);
}
return task.newListr(subTasks, {
concurrent: config.parallelDeploy,
rendererOptions: {collapseSubtasks: false},
});
},
},
],
{concurrent: false, rendererOptions: {collapseSubtasks: false}},
);
},
},
],
{concurrent: false, rendererOptions: {collapseSubtasks: false}},
);
},
};
return task.newListr(
[
invokeSoloCommand(
`solo ${BlockCommandDefinition.ADD_COMMAND}`,
BlockCommandDefinition.ADD_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...BlockCommandDefinition.ADD_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
);
// Build a local copy with the dev image values file appended, without mutating
// config.blockNodeConfiguration — it may be an alias for another section's object
// (e.g. via YAML anchors), causing the values file to leak into other commands.
const blockExistingValuesFile: string =
config.blockNodeConfiguration?.[flags.getFormattedFlagKey(Flags.valuesFile)];
const blockLocalConfig: AnyObject = {
...config.blockNodeConfiguration,
[flags.getFormattedFlagKey(Flags.valuesFile)]: blockExistingValuesFile
? `${blockExistingValuesFile},${constants.BLOCK_NODE_SOLO_DEV_FILE}`
: constants.BLOCK_NODE_SOLO_DEV_FILE,
};
this.appendConfigToArgv(argv, blockLocalConfig);
return argvPushGlobalFlags(argv);
},
this.taskList,
(): boolean => constants.ONE_SHOT_WITH_BLOCK_NODE.toLowerCase() !== 'true',
),
deployNetworkNodeTask,
invokeSoloCommand(
`solo ${MirrorCommandDefinition.ADD_COMMAND}`,
MirrorCommandDefinition.ADD_COMMAND,
(): string[] => {
const argv: string[] = newArgv();
argv.push(
...MirrorCommandDefinition.ADD_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
optionFromFlag(Flags.clusterRef),
config.clusterRef,
optionFromFlag(Flags.pinger),
optionFromFlag(Flags.enableIngress),
optionFromFlag(Flags.parallelDeploy),
config.parallelDeploy.toString(),
);
// Append HikariCP limits file without mutating the shared config object.
const mirrorExistingValuesFile: string =
config.mirrorNodeConfiguration?.[flags.getFormattedFlagKey(Flags.valuesFile)];
const mirrorLocalConfig: AnyObject = {
[optionFromFlag(Flags.externalAddress)]: config.externalAddress,
...config.mirrorNodeConfiguration,
[flags.getFormattedFlagKey(Flags.valuesFile)]: mirrorExistingValuesFile
? `${mirrorExistingValuesFile},${constants.MIRROR_NODE_HIKARI_LIMITS_FILE}`
: constants.MIRROR_NODE_HIKARI_LIMITS_FILE,
};
this.appendConfigToArgv(argv, mirrorLocalConfig);
return argvPushGlobalFlags(argv, config.cacheDir);
},
this.taskList,
(): boolean => !config.deployMirrorNode,
),
invokeSoloCommand(
`solo ${ExplorerCommandDefinition.ADD_COMMAND}`,
ExplorerCommandDefinition.ADD_COMMAND,
async (): Promise<string[]> => {
await this.eventBus.waitFor(
SoloEventType.MirrorNodeDeployed,
(soloEvent: MirrorNodeDeployedEvent): boolean => soloEvent.deployment === config.deployment,
Duration.ofMinutes(5),
);
const argv: string[] = newArgv();
argv.push(
...ExplorerCommandDefinition.ADD_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
optionFromFlag(Flags.clusterRef),
config.clusterRef,
);
this.appendConfigToArgv(argv, {
[optionFromFlag(Flags.externalAddress)]: config.externalAddress,
[optionFromFlag(Flags.explorerVersion)]: config.versions.explorer,
[optionFromFlag(Flags.mirrorNodeId)]: mirrorNodeId,
[optionFromFlag(Flags.mirrorNamespace)]: config.namespace.name,
...config.explorerNodeConfiguration,
});
return argvPushGlobalFlags(argv, config.cacheDir);
},
this.taskList,
(): boolean => !config.deployExplorer && !config.minimalSetup,
),
invokeSoloCommand(
`solo ${RelayCommandDefinition.ADD_COMMAND}`,
RelayCommandDefinition.ADD_COMMAND,
async (): Promise<string[]> => {
await this.eventBus.waitFor(
SoloEventType.MirrorNodeDeployed,
(soloEvent: MirrorNodeDeployedEvent): boolean => soloEvent.deployment === config.deployment,
Duration.ofMinutes(5),
);
await this.eventBus.waitFor(
SoloEventType.NodesStarted,
(soloEvent: NodesStartedEvent): boolean => soloEvent.deployment === config.deployment,
Duration.ofMinutes(5),
);
const argv: string[] = newArgv();
argv.push(
...RelayCommandDefinition.ADD_COMMAND.split(' '),
optionFromFlag(Flags.deployment),
config.deployment,
optionFromFlag(Flags.clusterRef),
config.clusterRef,
optionFromFlag(Flags.nodeAliasesUnparsed),
'node1',
);
this.appendConfigToArgv(argv, {
[optionFromFlag(Flags.externalAddress)]: config.externalAddress,
[optionFromFlag(Flags.mirrorNodeId)]: mirrorNodeId,
[optionFromFlag(Flags.mirrorNamespace)]: config.namespace.name,
...config.relayNodeConfiguration,
});
return argvPushGlobalFlags(argv);
},
this.taskList,
(): boolean => !config.deployRelay && !config.minimalSetup,
),
],
{concurrent: config.parallelDeploy, rendererOptions: {collapseSubtasks: false}},
);
},
},
{
title: 'Finish',
task: async (context_: OneShotSingleDeployContext): Promise<void> => {
const outputDirectory: string = this.getOneShotOutputDirectory(context_.config.deployment);
this.logger.info(`Output directory: ${outputDirectory}`);
this.showOneShotUserNotes(context_, false, PathEx.join(outputDirectory, 'notes'));
this.showVersions(PathEx.join(outputDirectory, 'versions'), config);
this.showPortForwards(PathEx.join(outputDirectory, 'forwards'));
this.showAccounts(context_.createdAccounts, context_, PathEx.join(outputDirectory, 'accounts.json'));
this.cacheDeploymentName(context_, PathEx.join(constants.SOLO_CACHE_DIR, 'last-one-shot-deployment.txt'));
return;
},
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
);
try {