@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
1,141 lines (1,036 loc) • 64.7 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import chalk from 'chalk';
import * as fs from 'node:fs';
import {BaseCommand} from './base.js';
import {IllegalArgumentError} from '../core/errors/illegal-argument-error.js';
import {SoloError} from '../core/errors/solo-error.js';
import {Flags as flags} from './flags.js';
import {Listr, type ListrContext, type ListrRendererValue} from 'listr2';
import * as constants from '../core/constants.js';
import * as helpers from '../core/helpers.js';
import {entityId} from '../core/helpers.js';
import {type AccountManager} from '../core/account-manager.js';
import {
AccountId,
AccountInfo,
Client,
Hbar,
HbarUnit,
Long,
NodeUpdateTransaction,
PrivateKey,
TransactionReceipt,
TransactionResponse,
} from '@hiero-ledger/sdk';
import {type ArgvStruct, type NodeAliases, NodeId} from '../types/aliases.js';
import {resolveNamespaceFromDeployment} from '../core/resolvers.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {
type ClusterReferenceName,
type Context,
type DeploymentName,
type Realm,
type Shard,
type AccountIdWithKeyPairObject,
type SoloListr,
type SoloListrTask,
type SoloListrTaskWrapper,
} from '../types/index.js';
import {Templates} from '../core/templates.js';
import {SecretType} from '../integration/kube/resources/secret/secret-type.js';
import {Base64} from 'js-base64';
import {inject, injectable} from 'tsyringe-neo';
import {InjectTokens} from '../core/dependency-injection/inject-tokens.js';
import {patchInject} from '../core/dependency-injection/container-helper.js';
import {CommandFlags} from '../types/flag-types.js';
import {Duration} from '../core/time/duration.js';
import {
type CreatedPredefinedAccount,
type PredefinedAccount,
PREDEFINED_ACCOUNT_GROUPS,
predefinedEcdsaAccountsWithAlias,
type SystemAccount,
} from './one-shot/predefined-accounts.js';
import {type Pod} from '../integration/kube/resources/pod/pod.js';
import {ContainerReference} from '../integration/kube/resources/container/container-reference.js';
import {LedgerPhase} from '../data/schema/model/remote/ledger-phase.js';
import {DeploymentPhase} from '../data/schema/model/remote/deployment-phase.js';
import {container} from 'tsyringe-neo';
import {PvcReference} from '../integration/kube/resources/pvc/pvc-reference.js';
import {PvcName} from '../integration/kube/resources/pvc/pvc-name.js';
import {type Secret} from '../integration/kube/resources/secret/secret.js';
import {type K8} from '../integration/kube/k8.js';
import * as CommandHelpers from './command-helpers.js';
import {invokeSoloCommand} from './command-helpers.js';
import {NodeCommandTasks} from './node/tasks.js';
import {ContainerName} from '../integration/kube/resources/container/container-name.js';
import {ConsensusCommandDefinition} from './command-definitions/consensus-command-definition.js';
import {OneShotCommandDefinition} from './command-definitions/one-shot-command-definition.js';
interface UpdateAccountConfig {
accountId: string;
amount: number;
namespace: NamespaceName;
deployment: DeploymentName;
ecdsaPrivateKey: string;
ed25519PrivateKey: string;
clusterRef: ClusterReferenceName;
contextName: string;
}
interface UpdateAccountContext {
config: UpdateAccountConfig;
accountInfo: {
accountId: AccountId | string;
balance: number;
publicKey: string;
privateKey?: string;
};
}
@injectable()
export class AccountCommand extends BaseCommand {
private static ACCOUNT_KEY_USER_MESSAGE: string =
'where:\n' +
'- privateKey: the hex-encoded private key which is used to sign transactions with in the Hiero SDKs\n' +
'- privateKeyRaw: the Ethereum compatible private key, without the `0x` prefix\n' +
'- for more information see: https://docs.hedera.com/hedera/core-concepts/keys-and-signatures';
private accountInfo:
| {
accountId: string;
balance: number;
publicKey: string;
privateKey?: string;
accountAlias?: string;
}
| undefined;
public constructor(
@inject(InjectTokens.AccountManager) private readonly accountManager: AccountManager,
@inject(InjectTokens.SystemAccounts) private readonly systemAccounts: number[][],
) {
super();
this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name);
this.accountInfo = undefined;
this.systemAccounts = patchInject(systemAccounts, InjectTokens.SystemAccounts, this.constructor.name);
}
public static INIT_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [flags.nodeAliasesUnparsed, flags.clusterRef],
};
public static RESET_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.deployment, flags.nodeAliasesUnparsed, flags.clusterRef],
};
public static CREATE_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [
flags.amount,
flags.createAmount,
flags.ecdsaPrivateKey,
flags.privateKey,
flags.ed25519PrivateKey,
flags.generateEcdsaKey,
flags.setAlias,
flags.clusterRef,
],
};
public static UPDATE_FLAGS_LIST: CommandFlags = {
required: [flags.accountId, flags.deployment],
optional: [flags.amount, flags.ecdsaPrivateKey, flags.ed25519PrivateKey, flags.clusterRef],
};
public static GET_FLAGS_LIST: CommandFlags = {
required: [flags.accountId, flags.deployment],
optional: [flags.privateKey, flags.clusterRef],
};
public static PREDEFINED_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [flags.clusterRef, flags.forcePortForward, flags.cacheDir, flags.devMode, flags.quiet],
};
private async closeConnections(): Promise<void> {
await this.accountManager.close();
}
private async buildAccountInfo(
accountInfo: AccountInfo,
namespace: NamespaceName,
shouldRetrievePrivateKey: boolean,
): Promise<{accountId: string; balance: number; publicKey: string; privateKey?: string; privateKeyRaw?: string}> {
if (!accountInfo || !(accountInfo instanceof AccountInfo)) {
throw new IllegalArgumentError('An instance of AccountInfo is required');
}
const newAccountInfo: {
accountId: string;
balance: number;
publicKey: string;
privateKey?: string;
privateKeyRaw?: string;
} = {
accountId: accountInfo.accountId.toString(),
publicKey: accountInfo.key.toString(),
balance: accountInfo.balance.to(HbarUnit.Hbar).toNumber(),
};
if (shouldRetrievePrivateKey) {
const accountKeys: AccountIdWithKeyPairObject = await this.accountManager.getAccountKeysFromSecret(
newAccountInfo.accountId,
namespace,
);
newAccountInfo.privateKey = accountKeys.privateKey;
// reconstruct private key to retrieve EVM address if private key is ECDSA type
try {
const privateKey: PrivateKey = PrivateKey.fromStringDer(newAccountInfo.privateKey);
newAccountInfo.privateKeyRaw = privateKey.toStringRaw();
} catch {
throw new SoloError(`failed to retrieve EVM address for accountId ${newAccountInfo.accountId}`);
}
}
return newAccountInfo;
}
public async createNewAccount(context_: {
config: {
generateEcdsaKey: boolean;
ecdsaPrivateKey?: string;
ed25519PrivateKey?: string;
namespace: NamespaceName;
setAlias: boolean;
amount: number;
contextName: string;
};
privateKey: PrivateKey;
}): Promise<{accountId: string; privateKey: string; publicKey: string; balance: number; accountAlias?: string}> {
if (context_.config.ecdsaPrivateKey) {
context_.privateKey = PrivateKey.fromStringECDSA(context_.config.ecdsaPrivateKey);
} else if (context_.config.ed25519PrivateKey) {
context_.privateKey = PrivateKey.fromStringED25519(context_.config.ed25519PrivateKey);
} else if (context_.config.generateEcdsaKey) {
context_.privateKey = PrivateKey.generateECDSA();
} else {
context_.privateKey = PrivateKey.generateED25519();
}
return await this.accountManager.createNewAccount(
context_.config.namespace,
context_.privateKey,
context_.config.amount,
context_.config.ecdsaPrivateKey || context_.config.generateEcdsaKey ? context_.config.setAlias : false,
context_.config.contextName,
);
}
private getAccountInfo(context_: {config: {accountId: string}}): Promise<AccountInfo> {
return this.accountManager.accountInfoQuery(context_.config.accountId);
}
private async updateAccountInfo(context_: UpdateAccountContext): Promise<boolean> {
let amount: number = context_.config.amount;
if (context_.config.ed25519PrivateKey) {
if (
!(await this.accountManager.sendAccountKeyUpdate(
context_.accountInfo.accountId,
context_.config.ed25519PrivateKey,
context_.accountInfo.privateKey,
))
) {
throw new SoloError(`failed to update account keys for accountId ${context_.accountInfo.accountId}`);
}
} else {
const defaultAmount: number = flags.amount.definition.defaultValue as number;
amount = amount || defaultAmount;
}
const hbarAmount: number = Number.parseFloat(amount.toString());
if (Number.isNaN(hbarAmount)) {
throw new SoloError(`The HBAR amount was invalid: ${amount}`);
}
if (hbarAmount > 0) {
const deployment: DeploymentName = context_.config.deployment;
if (!(await this.transferAmountFromOperator(context_.accountInfo.accountId, hbarAmount, deployment))) {
throw new SoloError(`failed to transfer amount for accountId ${context_.accountInfo.accountId}`);
}
this.logger.debug(`sent transfer amount for account ${context_.accountInfo.accountId}`);
}
return true;
}
private async transferAmountFromOperator(
toAccountId: AccountId | string,
amount: number,
deploymentName: DeploymentName,
): Promise<boolean> {
const operatorAccountId: AccountId = this.accountManager.getOperatorAccountId(deploymentName);
return await this.accountManager.transferAmount(operatorAccountId, toAccountId, amount);
}
public async init(argv: ArgvStruct): Promise<boolean> {
interface Config {
namespace: NamespaceName;
nodeAliases: NodeAliases;
clusterRef: ClusterReferenceName;
deployment: DeploymentName;
contextName: string;
}
interface Context {
config: Config;
updateSecrets: boolean;
accountsBatchedSet: number[][];
resultTracker: {
rejectedCount: number;
fulfilledCount: number;
skippedCount: number;
};
}
const tasks: Listr<Context, ListrRendererValue, ListrRendererValue> = new Listr(
[
{
title: 'Initialize',
task: async (context_: Context, task: SoloListrTaskWrapper<Context>): Promise<void> => {
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
flags.disablePrompts([flags.clusterRef]);
const clusterReference: ClusterReferenceName = this.getClusterReference();
const contextName: string = this.getClusterContext(clusterReference);
const config: Config = {
deployment: this.configManager.getFlag<DeploymentName>(flags.deployment),
clusterRef: clusterReference,
contextName,
namespace: await this.resolveNamespaceFromDeployment(task),
nodeAliases: helpers.parseNodeAliases(
this.configManager.getFlag(flags.nodeAliasesUnparsed),
this.remoteConfig.getConsensusNodes(),
this.configManager,
),
};
await this.throwIfNamespaceIsMissing(config.contextName, config.namespace);
// set config in the context for later tasks to use
context_.config = config;
await this.accountManager.loadNodeClient(
config.namespace,
this.remoteConfig.getClusterRefs(),
this.configManager.getFlag<DeploymentName>(flags.deployment),
this.configManager.getFlag<boolean>(flags.forcePortForward),
);
},
},
{
title: 'Update special account keys',
task: (): SoloListr<Context> => {
return new Listr(
[
{
title: 'Prepare for account key updates',
task: async (context_: Context): Promise<void> => {
const config: Config = context_.config;
context_.updateSecrets = await this.k8Factory
.getK8(config.contextName)
.secrets()
.list(config.namespace, ['solo.hedera.com/account-id'])
.then((secrets): boolean => secrets.length > 0);
context_.accountsBatchedSet = this.accountManager.batchAccounts(this.systemAccounts);
context_.resultTracker = {
rejectedCount: 0,
fulfilledCount: 0,
skippedCount: 0,
};
// do a write transaction to trigger the handler and generate the system accounts to complete genesis
const deployment: DeploymentName = config.deployment;
const treasuryAccountId: AccountId = this.accountManager.getTreasuryAccountId(deployment);
const freezeAccountId: AccountId = this.accountManager.getFreezeAccountId(deployment);
await this.accountManager.transferAmount(treasuryAccountId, freezeAccountId, 1);
},
},
{
title: 'Update special account key sets',
task: (context_: Context, task: SoloListrTaskWrapper<Context>): SoloListr<Context> => {
const config: Config = context_.config;
const subTasks: SoloListrTask<Context>[] = [];
const realm: Realm = this.localConfig.configuration.realmForDeployment(config.deployment);
const shard: Shard = this.localConfig.configuration.shardForDeployment(config.deployment);
for (const currentSet of context_.accountsBatchedSet) {
const accountStart: string = entityId(shard, realm, currentSet[0]);
const accountEnd: string = entityId(shard, realm, currentSet.at(-1));
const rangeString: string =
accountStart === accountEnd
? `${chalk.yellow(accountStart)}`
: `${chalk.yellow(accountStart)} to ${chalk.yellow(accountEnd)}`;
subTasks.push({
title: `Updating accounts [${rangeString}]`,
task: async (context_: Context): Promise<void> => {
const config: Config = context_.config;
context_.resultTracker = await this.accountManager.updateSpecialAccountsKeys(
config.namespace,
currentSet,
context_.updateSecrets,
context_.resultTracker,
config.deployment,
);
},
});
}
// set up the sub-tasks
return task.newListr(subTasks, {
concurrent: false,
rendererOptions: {
collapseSubtasks: false,
},
});
},
},
{
title: 'Update node admin key',
task: async ({config}: Context): Promise<void> => {
const adminKey: PrivateKey = PrivateKey.fromStringED25519(constants.GENESIS_KEY);
for (const nodeAlias of config.nodeAliases) {
const nodeId: NodeId = Templates.nodeIdFromNodeAlias(nodeAlias);
const nodeClient: Client = await this.accountManager.refreshNodeClient(
config.namespace,
this.remoteConfig.getClusterRefs(),
nodeAlias,
config.deployment,
);
try {
let nodeUpdateTx: NodeUpdateTransaction = new NodeUpdateTransaction().setNodeId(
new Long(nodeId),
);
const newPrivateKey: PrivateKey = PrivateKey.generateED25519();
nodeUpdateTx = nodeUpdateTx.setAdminKey(newPrivateKey.publicKey);
nodeUpdateTx = nodeUpdateTx.freezeWith(nodeClient);
nodeUpdateTx = await nodeUpdateTx.sign(newPrivateKey);
const signedTx: NodeUpdateTransaction = await nodeUpdateTx.sign(adminKey);
const txResp: TransactionResponse = await signedTx.execute(nodeClient);
const nodeUpdateReceipt: TransactionReceipt = await txResp.getReceipt(nodeClient);
this.logger.debug(`NodeUpdateReceipt: ${nodeUpdateReceipt.toString()} for node ${nodeAlias}`);
// save new key in k8s secret
const data: {privateKey: string; publicKey: string} = {
privateKey: Base64.encode(newPrivateKey.toString()),
publicKey: Base64.encode(newPrivateKey.publicKey.toString()),
};
await this.k8Factory
.getK8(config.contextName)
.secrets()
.create(
config.namespace,
Templates.renderNodeAdminKeyName(nodeAlias),
SecretType.OPAQUE,
data,
{'solo.hedera.com/node-admin-key': 'true'},
);
} catch (error) {
throw new SoloError(`Error updating admin key for node ${nodeAlias}: ${error.message}`, error);
}
}
},
},
{
title: 'Display results',
task: ({resultTracker: {fulfilledCount, skippedCount, rejectedCount}}: Context): void => {
this.logger.showUser(chalk.green(`Account keys updated SUCCESSFULLY: ${fulfilledCount}`));
if (skippedCount > 0) {
this.logger.showUser(chalk.cyan(`Account keys updates SKIPPED: ${skippedCount}`));
}
if (rejectedCount > 0) {
this.logger.showUser(chalk.yellowBright(`Account keys updates with ERROR: ${rejectedCount}`));
}
this.logger.showUser(chalk.gray('Waiting for sockets to be closed....'));
if (rejectedCount > 0) {
throw new SoloError(`Account keys updates failed for ${rejectedCount} accounts.`);
}
},
},
],
{
concurrent: false,
rendererOptions: {
collapseSubtasks: false,
},
},
);
},
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
);
try {
await tasks.run();
} catch (error) {
throw new SoloError(`Error in creating account: ${error.message}`, error);
} finally {
await this.closeConnections();
// create two accounts to force the handler to trigger
await this.create(argv);
await this.create(argv);
}
return true;
}
public async resetSystem(argv: ArgvStruct): Promise<boolean> {
interface Config {
deployment: DeploymentName;
namespace: NamespaceName;
nodeAliases: NodeAliases;
}
interface ResetContext {
config: Config;
}
const shouldSkipConsensusPodRestart: boolean = process.env.SOLO_LEDGER_RESET_SKIP_POD_RESTART !== 'false';
const tasks: Listr<ResetContext, ListrRendererValue, ListrRendererValue> = new Listr(
[
{
title: 'Identify nodes',
task: async (context_, task: SoloListrTaskWrapper<ResetContext>): Promise<void> => {
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
const deployment: DeploymentName =
this.configManager.getFlag<DeploymentName>(flags.deployment) ?? OneShotCommandDefinition.COMMAND_NAME;
const namespace: NamespaceName = await resolveNamespaceFromDeployment(
this.localConfig,
this.configManager,
task,
);
const nodeAliases: NodeAliases = helpers.parseNodeAliases(
this.configManager.getFlag(flags.nodeAliasesUnparsed),
this.remoteConfig.getConsensusNodes(),
this.configManager,
);
const nodeTasks: NodeCommandTasks = container.resolve<NodeCommandTasks>(NodeCommandTasks);
const resolvedNodeAliases: NodeAliases =
nodeAliases.length > 0 ? nodeAliases : await nodeTasks.getExistingNodeAliases(namespace, deployment);
if (resolvedNodeAliases.length === 0) {
throw new SoloError('No consensus nodes found to reset; check your deployment or --node-aliases input.');
}
context_.config = {
deployment,
namespace,
nodeAliases: resolvedNodeAliases,
};
this.logger.debug(`context_.config = ${JSON.stringify(context_.config)}`);
},
},
{
title: 'Stop consensus nodes',
task: async (
context_,
task,
): Promise<
| Listr<ListrContext, ListrRendererValue, ListrRendererValue>
| Listr<ListrContext, ListrRendererValue, ListrRendererValue>[]
> =>
invokeSoloCommand(
'Stop consensus nodes',
`${ConsensusCommandDefinition.COMMAND_NAME} ${ConsensusCommandDefinition.NODE_SUBCOMMAND_NAME} ${ConsensusCommandDefinition.NODE_STOP}`,
(): string[] => {
const commandArgv: string[] = CommandHelpers.newArgv();
commandArgv.push(
ConsensusCommandDefinition.COMMAND_NAME,
ConsensusCommandDefinition.NODE_SUBCOMMAND_NAME,
ConsensusCommandDefinition.NODE_STOP,
CommandHelpers.optionFromFlag(flags.deployment),
context_.config.deployment,
CommandHelpers.optionFromFlag(flags.nodeAliasesUnparsed),
context_.config.nodeAliases.join(','),
);
return commandArgv;
},
this.taskList,
).task(context_, task),
},
{
title: 'Change node state to frozen in remote config',
task: async (context_): Promise<void> => {
for (const nodeAlias of context_.config.nodeAliases) {
this.remoteConfig.configuration.components.changeNodePhase(
Templates.renderComponentIdFromNodeAlias(nodeAlias),
DeploymentPhase.FROZEN,
);
}
await this.remoteConfig.persist();
},
},
{
title: 'Scale down block node StatefulSet(s)',
skip: (): boolean => this.remoteConfig.configuration.state.blockNodes.length === 0,
task: async (): Promise<void> => {
for (const blockNode of this.remoteConfig.configuration.state.blockNodes) {
const context: Context | undefined = this.remoteConfig.getClusterRefs().get(blockNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for block node ${blockNode.metadata.id}`);
}
const namespace: string = blockNode.metadata.namespace.toString();
const statefulSetName: string = Templates.renderBlockNodeName(blockNode.metadata.id);
await this.k8Factory.getK8(context).manifests().scaleStatefulSet(namespace, statefulSetName, 0);
}
},
},
{
title: 'Scale down mirror importer deployment(s)',
skip: (): boolean => this.remoteConfig.configuration.state.mirrorNodes.length === 0,
task: async (): Promise<void> => {
for (const mirrorNode of this.remoteConfig.configuration.state.mirrorNodes) {
const context: Context | undefined = this.remoteConfig.getClusterRefs().get(mirrorNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for mirror node ${mirrorNode.metadata.id}`);
}
const namespaceName: NamespaceName = NamespaceName.of(mirrorNode.metadata.namespace);
const {mirrorNodeReleaseName} = await this.inferMirrorNodeData(namespaceName, context);
const importerDeploymentName: string = `${mirrorNodeReleaseName}-importer`;
await this.k8Factory
.getK8(context)
.manifests()
.scaleDeployment(namespaceName.toString(), importerDeploymentName, 0);
}
},
},
{
title: 'Reset mirror object storage streams',
skip: (): boolean => this.remoteConfig.configuration.state.mirrorNodes.length === 0,
task: async (): Promise<void> => {
for (const mirrorNode of this.remoteConfig.configuration.state.mirrorNodes) {
const context: Context | undefined = this.remoteConfig.getClusterRefs().get(mirrorNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for mirror node ${mirrorNode.metadata.id}`);
}
const namespace: NamespaceName = NamespaceName.of(mirrorNode.metadata.namespace);
const k8: K8 = this.k8Factory.getK8(context);
const minioPods: Pod[] = await k8.pods().list(namespace, ['v1.min.io/tenant=minio']);
for (const minioPod of minioPods) {
await k8
.containers()
.readByRef(ContainerReference.of(minioPod.podReference, ContainerName.of('minio')))
.execContainer(['sh', '-c', 'rm -rf /export/data/solo-streams/*']);
}
}
},
},
{
title: 'Truncate mirror postgres data',
skip: (): boolean => this.remoteConfig.configuration.state.mirrorNodes.length === 0,
task: async (): Promise<void> => {
const truncateSql: string = fs.readFileSync(constants.MIRROR_POSTGRES_TRUNCATE_SQL_FILE, 'utf8');
for (const mirrorNode of this.remoteConfig.configuration.state.mirrorNodes) {
const context: Context | undefined = this.remoteConfig.getClusterRefs().get(mirrorNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for mirror node ${mirrorNode.metadata.id}`);
}
const namespace: NamespaceName = NamespaceName.of(mirrorNode.metadata.namespace);
const k8: K8 = this.k8Factory.getK8(context);
const postgresPods: Pod[] = await k8.pods().list(namespace, [constants.SOLO_MIRROR_POSTGRES_NAME_LABEL]);
if (postgresPods.length === 0) {
throw new SoloError(`postgres pod not found in namespace ${namespace}`);
}
const postgresPod: Pod = postgresPods[0];
const postgresContainerReference: ContainerReference = ContainerReference.of(
postgresPod.podReference,
ContainerName.of('postgresql'),
);
const mirrorPasswordsSecret: Secret = await k8.secrets().read(namespace, 'mirror-passwords');
const ownerKey: string | undefined = Object.keys(mirrorPasswordsSecret.data).find(
(key: string): boolean => key.endsWith('_MIRROR_IMPORTER_DB_OWNER'),
);
if (!ownerKey) {
throw new SoloError('Could not find MIRROR_IMPORTER_DB_OWNER in mirror-passwords secret.');
}
const environmentVariablePrefix: string = ownerKey.replace('_MIRROR_IMPORTER_DB_OWNER', '');
const databaseOwner: string = Base64.decode(
mirrorPasswordsSecret.data[`${environmentVariablePrefix}_MIRROR_IMPORTER_DB_OWNER`],
);
const databaseOwnerPassword: string = Base64.decode(
mirrorPasswordsSecret.data[`${environmentVariablePrefix}_MIRROR_IMPORTER_DB_OWNERPASSWORD`],
);
const databaseName: string = Base64.decode(
mirrorPasswordsSecret.data[`${environmentVariablePrefix}_MIRROR_IMPORTER_DB_NAME`],
);
await k8
.containers()
.readByRef(postgresContainerReference)
.execContainer([
'psql',
`postgresql://${databaseOwner}:${databaseOwnerPassword}@localhost:5432/${databaseName}`,
'-v',
'ON_ERROR_STOP=1',
'-c',
truncateSql,
]);
}
},
},
{
title: 'Flush mirror redis cache',
skip: (): boolean => this.remoteConfig.configuration.state.mirrorNodes.length === 0,
task: async (): Promise<void> => {
for (const mirrorNode of this.remoteConfig.configuration.state.mirrorNodes) {
const context: Context | undefined = this.remoteConfig.getClusterRefs().get(mirrorNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for mirror node ${mirrorNode.metadata.id}`);
}
const namespace: NamespaceName = NamespaceName.of(mirrorNode.metadata.namespace);
const k8: K8 = this.k8Factory.getK8(context);
const redisPods: Pod[] = await k8.pods().list(namespace, [constants.SOLO_MIRROR_REDIS_NAME_LABEL]);
for (const redisPod of redisPods) {
const redisContainerReference: ContainerReference = ContainerReference.of(
redisPod.podReference,
ContainerName.of('redis'),
);
await k8
.containers()
.readByRef(redisContainerReference)
.execContainer([
'bash',
'-c',
// Credentials are read from the mounted secret file or the REDIS_PASSWORD env var
// already present inside the container — never passed as a CLI argument.
// REDISCLI_AUTH is the env var that redis-cli reads natively, so the password
// never appears in the process argument list and is not visible in `ps` output.
'PASSWORD_FILE="${REDIS_PASSWORD_FILE:-/opt/bitnami/redis/secrets/redis-password}"; ' +
'export REDISCLI_AUTH="${REDIS_PASSWORD:-$(cat "$PASSWORD_FILE" 2>/dev/null)}"; ' +
'if [ -z "$REDISCLI_AUTH" ]; then echo "REDIS password not found" >&2; exit 1; fi; ' +
'if command -v redis-cli >/dev/null 2>&1; then ' +
' redis-cli FLUSHALL; ' +
'else ' +
' /opt/bitnami/redis/bin/redis-cli FLUSHALL; ' +
'fi',
]);
}
}
},
},
{
title: 'Delete ledger account secrets',
task: async (context_): Promise<void> => {
for (const [, context] of this.remoteConfig.getClusterRefs()) {
const secrets: Secret[] = await this.k8Factory
.getK8(context)
.secrets()
.list(context_.config.namespace, ['solo.hedera.com/account-id']);
for (const secret of secrets) {
await this.k8Factory.getK8(context).secrets().delete(context_.config.namespace, secret.name);
}
}
},
},
{
title: 'Clear consensus node saved state',
task: async (context_, task: SoloListrTaskWrapper<ResetContext>): Promise<SoloListr<ResetContext>> => {
const subTasks: SoloListrTask<ResetContext>[] = [];
this.logger.debug(`context_.config = ${JSON.stringify(context_.config)}`);
const nodeAliases: NodeAliases = context_.config.nodeAliases;
if (!nodeAliases || nodeAliases.length === 0) {
throw new SoloError('No consensus nodes found to reset; check your deployment or --node-aliases input.');
}
for (const nodeAlias of nodeAliases) {
const resolvedContext: string =
this.remoteConfig.extractContextFromConsensusNodes(nodeAlias) ??
this.k8Factory.default().contexts().readCurrent();
const k8: K8 = this.k8Factory.getK8(resolvedContext);
const pods: Pod[] = await k8
.pods()
.list(context_.config.namespace, [
`solo.hedera.com/node-name=${nodeAlias}`,
'solo.hedera.com/type=network-node',
]);
for (const pod of pods) {
const containerReference: ContainerReference = ContainerReference.of(
pod.podReference,
constants.ROOT_CONTAINER,
);
subTasks.push({
title: `Node ${nodeAlias}: ${pod.podReference.name}`,
task: async (): Promise<void> => {
await k8
.containers()
.readByRef(containerReference)
.execContainer([
'bash',
'-c',
`rm -rf ${constants.HEDERA_HAPI_PATH}/data/saved/*; ` +
'rm -rf /opt/hgcapp/recordStreams/* /opt/hgcapp/recordStreams/.[!.]* /opt/hgcapp/recordStreams/..?*; ' +
'rm -rf /opt/hgcapp/eventsStreams/* /opt/hgcapp/eventsStreams/.[!.]* /opt/hgcapp/eventsStreams/..?*; ' +
'rm -rf /opt/hgcapp/blockStreams/* /opt/hgcapp/blockStreams/.[!.]* /opt/hgcapp/blockStreams/..?*; ' +
`if [ -f ${constants.HEDERA_HAPI_PATH}/data/config/.archive/genesis-network.json ]; then ` +
`cp ${constants.HEDERA_HAPI_PATH}/data/config/.archive/genesis-network.json ${constants.HEDERA_HAPI_PATH}/data/config/genesis-network.json; ` +
`else echo "ERROR: missing ${constants.HEDERA_HAPI_PATH}/data/config/.archive/genesis-network.json" >&2; exit 1; fi`,
]);
},
});
}
}
return task.newListr(subTasks, constants.LISTR_DEFAULT_OPTIONS.WITH_CONCURRENCY);
},
},
{
title: 'Optional: recreate consensus node pods and reset persisted state',
skip: (): boolean => shouldSkipConsensusPodRestart,
task: async (context_, task: SoloListrTaskWrapper<ResetContext>): Promise<SoloListr<ResetContext>> => {
const nodeAliases: NodeAliases = context_.config.nodeAliases;
const subTasks: SoloListrTask<ResetContext>[] = nodeAliases.map(
(nodeAlias): SoloListrTask<ResetContext> => ({
title: `Recreate ${nodeAlias}`,
task: async (): Promise<void> => {
const resolvedContext: string =
this.remoteConfig.extractContextFromConsensusNodes(nodeAlias) ??
this.k8Factory.default().contexts().readCurrent();
const k8: K8 = this.k8Factory.getK8(resolvedContext);
const labels: string[] = [
`solo.hedera.com/node-name=${nodeAlias}`,
'solo.hedera.com/type=network-node',
];
const pods: Pod[] = await k8.pods().list(context_.config.namespace, labels);
for (const pod of pods) {
const podName: string = pod.podReference.name.toString();
await k8.pods().delete(pod.podReference);
// Reset the PVC-backed stream and saved-state storage, but leave stable pod readiness
// checks to the later node-start path. That path already waits for a stable ready pod
// immediately before exec'ing into it, so only waiting for replacement pod creation
// here avoids paying the same 15s settle cost twice.
const resetPvcNames: string[] = [
`hgcapp-record-streams-pvc-${podName}`,
`hgcapp-event-streams-pvc-${podName}`,
`hgcapp-blockstream-pvc-${podName}`,
`hgcapp-data-saved-pvc-${podName}`,
`hgcapp-state-pvc-${podName}`,
];
await Promise.all(
resetPvcNames.map(async (pvcName: string): Promise<void> => {
try {
await k8.pvcs().delete(PvcReference.of(context_.config.namespace, PvcName.of(pvcName)));
} catch (error) {
this.logger.debug(
`Skipping reset PVC deletion for ${pvcName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}),
);
}
await k8.pods().waitForRunningPhase(context_.config.namespace, labels, 120, 1000);
},
}),
);
return task.newListr(subTasks, constants.LISTR_DEFAULT_OPTIONS.WITH_CONCURRENCY);
},
},
{
title: 'Reset block node PVCs',
skip: (): boolean => this.remoteConfig.configuration.state.blockNodes.length === 0,
task: async (): Promise<void> => {
for (const blockNode of this.remoteConfig.configuration.state.blockNodes) {
const context: Context | undefined = this.remoteConfig.getClusterRefs().get(blockNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for block node ${blockNode.metadata.id}`);
}
const releaseName: string = Templates.renderBlockNodeName(blockNode.metadata.id);
const pvcs: string[] = await this.k8Factory
.getK8(context)
.pvcs()
.list(NamespaceName.of(blockNode.metadata.namespace), [`app.kubernetes.io/instance=${releaseName}`]);
for (const pvc of pvcs) {
await this.k8Factory
.getK8(context)
.pvcs()
.delete(PvcReference.of(NamespaceName.of(blockNode.metadata.namespace), PvcName.of(pvc)));
}
}
},
},
{
title: 'Reset ledger phase to uninitialized',
task: async (): Promise<void> => {
this.remoteConfig.configuration.state.ledgerPhase = LedgerPhase.UNINITIALIZED;
await this.remoteConfig.persist();
},
},
{
title: 'Bring services back online',
task: async (_context_, task: SoloListrTaskWrapper<ResetContext>): Promise<SoloListr<ResetContext>> =>
task.newListr(
[
{
title: 'Scale up block node StatefulSet(s)',
skip: (): boolean => this.remoteConfig.configuration.state.blockNodes.length === 0,
task: async (): Promise<void> => {
for (const blockNode of this.remoteConfig.configuration.state.blockNodes) {
const context: Context | undefined = this.remoteConfig
.getClusterRefs()
.get(blockNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for block node ${blockNode.metadata.id}`);
}
const namespace: string = blockNode.metadata.namespace.toString();
const statefulSetName: string = Templates.renderBlockNodeName(blockNode.metadata.id);
await this.k8Factory.getK8(context).manifests().scaleStatefulSet(namespace, statefulSetName, 1);
}
},
},
{
title: 'Scale up mirror importer deployment(s)',
skip: (): boolean => this.remoteConfig.configuration.state.mirrorNodes.length === 0,
task: async (): Promise<void> => {
for (const mirrorNode of this.remoteConfig.configuration.state.mirrorNodes) {
const context: Context | undefined = this.remoteConfig
.getClusterRefs()
.get(mirrorNode.metadata.cluster);
if (!context) {
throw new SoloError(`No cluster context found for mirror node ${mirrorNode.metadata.id}`);
}
const namespaceName: NamespaceName = NamespaceName.of(mirrorNode.metadata.namespace);
const {mirrorNodeReleaseName} = await this.inferMirrorNodeData(namespaceName, context);
const importerDeploymentName: string = `${mirrorNodeReleaseName}-importer`;
const k8: K8 = this.k8Factory.getK8(context);
await k8.manifests().scaleDeployment(namespaceName.toString(), importerDeploymentName, 1);
await k8
.pods()
.waitForReadyStatus(
namespaceName,
[
'app.kubernetes.io/name=importer',
'app.kubernetes.io/component=importer',
`app.kubernetes.io/instance=${mirrorNodeReleaseName}`,
],
constants.PODS_READY_MAX_ATTEMPTS,
constants.PODS_READY_DELAY,
);
}
},
},
{
title: 'Start consensus node services',
task: async (
context_,
task,
): Promise<
| Listr<ListrContext, ListrRendererValue, ListrRendererValue>
| Listr<ListrContext, ListrRendererValue, ListrRendererValue>[]
> => {
const nodeAliases: NodeAliases = context_.config.nodeAliases;
if (!nodeAliases || nodeAliases.length === 0) {
throw new SoloError(
'No consensus nodes found to start; check your deployment or --node-aliases input.',
);
}
return invokeSoloCommand(
'Start consensus nodes',
ConsensusCommandDefinition.START_COMMAND,
(): string[] => {
const argv: string[] = CommandHelpers.newArgv();
argv.push(
ConsensusCommandDefinition.COMMAND_NAME,
ConsensusCommandDefinition.NODE_SUBCOMMAND_NAME,
ConsensusCommandDefinition.NODE_START,
CommandHelpers.optionFromFlag(flags.deployment),
context_.config.deployment,
CommandHelpers.optionFromFlag(flags.nodeAliasesUnparsed),
nodeAliases.join(','),
);
return argv;
},
this.taskList,
).task(context_, task);
},
},
],
constants.LISTR_DEFAULT_OPTIONS.WITH_CONCURRENCY,
),
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
);
await tasks.run();
return true;
}
public async create(argv: ArgvStruct): Promise<boolean> {
interface Config {
amount: number;
ecdsaPrivateKey: string;
ed25519PrivateKey: string;
namespace: NamespaceName;
privateKey: boolean;
deployment: DeploymentName;
setAlias: boolean;
generateEcdsaKey: boolean;
createAmount: number;
contextName: string;
clusterRef: ClusterReferenceName;
}
interface Context {
config: Config;
privateKey: PrivateKey;
}
const tasks: Listr<Context, ListrRendererValue, ListrRendererValue> = new Listr(
[
{
title: 'Initialize',
task: async (context_: Context, task: SoloListrTaskWrapper<Context>): Promise<void> => {
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
flags.disablePrompts([flags.clusterRef]);
const config: Config = {
amount: this.configManager.getFlag(flags.amount),
ecdsaPrivateKey: this.configManager.getFlag(flags.ecdsaPrivateKey),
namespace: await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task),
deployment: this.configManager.getFlag(flags.deployment),
ed25519PrivateKey: this.configManager.getFlag(flags.ed25519PrivateKey),
setAlias: this.configManager.getFlag(flags.setAlias),
generateEcdsaKey: this.configManager.getFlag(flags.generateEcdsaKey),
privateKey: this.configManager.getFlag(flags.privateKey),
createAmount: this.configManager.getFlag(flags.createAmount),
clusterRef: this.configManager.getFlag(flags.clusterRef),
} as Config;
config.contextName =
this.localConfig.configuration.clusterRefs.get(config.clusterRef)?.toString() ??
this.k8Factory.default().contexts().readCurrent();
if (!config.amount) {
config.amount = flags.amount.definition.defaultValue as number;
}
if (!(await this.k8Factory.getK8(config.contextName).namespaces().has(config.namespace))) {
throw new SoloError(`namespace ${config.namespace} does not exist`);
}
// set config in the context for later tasks to use
context_.config = config;
await this.accountManager.loadNodeClient(
context_.config.namespace,
this.remoteConfig.getClusterRefs(),
config.deployment,
this.configManager.getFlag<boolean>(flags.forcePortForward),
);
},
},
{
title: 'create the new account',
task: async (context_: Context, task: SoloListrTaskWrapper<Context>): Promise<SoloListr<Context>> => {
const subTasks: SoloListrTask<Context>[] = [];
for (let index: number = 0; index < context_.config.createAmount; index++) {
subTasks.push({
title: `Create accounts [${index}]`,
task: async (context_: Context): Promise<void> => {
this.accountInfo = await this.createNewAccount(context_);
const accountInfoCopy: {
accountId: string;
balance: number;
publicKey: string;
privateKey?: string;
accountAlias?: string;
} = {...this.accountInfo};
if (!context_.config.privateKey) {
delete accountInfoCopy.privateKey;
}
this.logger.showJSON('new account created', accountInfoCopy);
if (context_.config.privateKey) {
this.logger.showUser(AccountCommand.ACCOUNT_KEY_USER_MESSAGE);
}
},
});
}
// set up the sub-tasks
return task.newListr(subTasks, {
concurrent: 8,
rendererOptions: {
collapseSubtasks: false,
},
});
},
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
);
try {
await tasks.run();
} catch (error) {
throw new SoloError(`Error in creating account: ${error.message}`, error);
} finally {
await this.closeConnections();
}
return true;
}
public async update(argv: ArgvStruct): Promise<boolean> {
const tasks: Listr<UpdateAccountContext, ListrRendererValue, ListrRendererValue> = new Listr(
[
{
titl