@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
871 lines (870 loc) • 73.6 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var BackupRestoreCommand_1;
import { BaseCommand } from './base.js';
import { Flags as flags } from './flags.js';
import { injectable, container } from 'tsyringe-neo';
import chalk from 'chalk';
import yaml from 'yaml';
import fs from 'node:fs';
import path from 'node:path';
import { NamespaceName } from '../types/namespace/namespace-name.js';
import { SoloError } from '../core/errors/solo-error.js';
import { Listr } from 'listr2';
import * as constants from '../core/constants.js';
import * as helpers from '../core/helpers.js';
import { Duration } from '../core/time/duration.js';
import { ContainerReference } from '../integration/kube/resources/container/container-reference.js';
import { plainToInstance } from 'class-transformer';
import { RemoteConfigSchema } from '../data/schema/model/remote/remote-config-schema.js';
import { RemoteConfig } from '../business/runtime-state/config/remote/remote-config.js';
import { KeysCommandDefinition } from './command-definitions/keys-command-definition.js';
import { ConsensusCommandDefinition } from './command-definitions/consensus-command-definition.js';
import { BlockCommandDefinition } from './command-definitions/block-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 { ClusterReferenceCommandDefinition } from './command-definitions/cluster-reference-command-definition.js';
import { DeploymentCommandDefinition } from './command-definitions/deployment-command-definition.js';
import * as CommandHelpers from './command-helpers.js';
import { optionFromFlag, subTaskSoloCommand, invokeSoloCommand } from './command-helpers.js';
import { inject } from 'tsyringe-neo';
import { InjectTokens } from '../core/dependency-injection/inject-tokens.js';
import { patchInject } from '../core/dependency-injection/container-helper.js';
import { ShellRunner } from '../core/shell-runner.js';
import { PathEx } from '../business/utils/path-ex.js';
import { Chart } from '../integration/helm/model/chart.js';
import { Repository } from '../integration/helm/model/repository.js';
import { InstallChartOptionsBuilder } from '../integration/helm/model/install/install-chart-options-builder.js';
let BackupRestoreCommand = class BackupRestoreCommand extends BaseCommand {
static { BackupRestoreCommand_1 = this; }
kindBuilder;
kubectlInstallationDirectory;
constructor(kindBuilder, kubectlInstallationDirectory) {
super();
this.kindBuilder = kindBuilder;
this.kubectlInstallationDirectory = kubectlInstallationDirectory;
this.kindBuilder = patchInject(kindBuilder, InjectTokens.KindBuilder, BackupRestoreCommand_1.name);
this.kubectlInstallationDirectory = patchInject(kubectlInstallationDirectory, InjectTokens.KubectlInstallationDirectory, BackupRestoreCommand_1.name);
}
async close() {
// No resources to close for this command
}
static BACKUP_FLAGS_LIST = {
required: [flags.deployment],
optional: [flags.quiet, flags.outputDir, flags.zipPassword, flags.zipFile],
};
static RESTORE_CONFIG_FLAGS_LIST = {
required: [flags.deployment],
optional: [flags.quiet, flags.inputDir],
};
static RESTORE_CLUSTERS_FLAGS_LIST = {
required: [flags.inputDir],
optional: [flags.quiet, flags.optionsFile, flags.metallbConfig, flags.zipPassword, flags.zipFile],
};
static RESTORE_NETWORK_FLAGS_LIST = {
required: [flags.inputDir],
optional: [flags.quiet, flags.optionsFile, flags.shard, flags.realm],
};
/**
* Generic export function for Kubernetes resources from multiple clusters
* @param outputDirectory - directory to export resources to
* @param resourceType - type of resource ('configmaps' or 'secrets')
* @returns total number of resources exported across all clusters
*/
async exportResources(outputDirectory, resourceType) {
try {
const namespace = this.remoteConfig.getNamespace();
const clusterReferences = this.remoteConfig.getClusterRefs();
this.logger.showUser(chalk.cyan(`\nExporting ${resourceType} from namespace: ${namespace.toString()} across ${clusterReferences.size} cluster(s)`));
let totalExportedCount = 0;
// Iterate through each cluster
for (const [clusterReference, context] of clusterReferences.entries()) {
this.logger.showUser(chalk.cyan(`\n Processing cluster: ${clusterReference} (context: ${context})`));
const k8 = this.k8Factory.getK8(context);
// Create output directory using cluster reference (not context)
const contextDirectory = PathEx.join(outputDirectory, clusterReference, resourceType);
if (!fs.existsSync(contextDirectory)) {
fs.mkdirSync(contextDirectory, { recursive: true });
}
// Fetch resources based on type
let resources;
let totalCount;
if (resourceType === 'configmaps') {
resources = await k8.configMaps().list(namespace, []);
totalCount = resources.length;
}
else {
// For secrets, filter to only include Opaque type
const allSecrets = await k8.secrets().list(namespace, []);
resources = allSecrets.filter((secret) => secret.type === 'Opaque');
totalCount = allSecrets.length;
}
if (resources.length === 0) {
const message = resourceType === 'secrets'
? ' No Opaque secrets found in this cluster'
: ` No ${resourceType} found in this cluster`;
this.logger.showUser(chalk.yellow(message));
continue;
}
const countMessage = resourceType === 'secrets' && totalCount !== resources.length
? ` Found ${resources.length} Opaque secret(s) (filtered from ${totalCount} total)`
: ` Found ${resources.length} ${resourceType}`;
this.logger.showUser(chalk.white(countMessage));
// Export each resource as YAML
for (const resource of resources) {
const fileName = `${resource.name}.yaml`;
const filePath = PathEx.join(contextDirectory, fileName);
// Create a Kubernetes-compatible resource object
const k8sResource = {
apiVersion: 'v1',
kind: resourceType === 'configmaps' ? 'ConfigMap' : 'Secret',
metadata: {
name: resource.name,
namespace: resource.namespace.toString(),
labels: resource.labels || {},
annotations: {
'solo.hedera.com/cluster-context': context,
},
},
data: resource.data || {},
};
// Add type field for secrets
if (resourceType === 'secrets') {
k8sResource.type = resource.type || 'Opaque';
}
// Convert to YAML and write to file
const yamlContent = yaml.stringify(k8sResource, { sortMapEntries: true });
fs.writeFileSync(filePath, yamlContent, 'utf8');
}
this.logger.showUser(chalk.green(` ✓ Exported ${resources.length} ${resourceType} from context: ${context}`));
totalExportedCount += resources.length;
}
this.logger.showUser(chalk.green(`\n✓ Total exported: ${totalExportedCount} ${resourceType} from ${clusterReferences.size} cluster(s) to ${outputDirectory}/${resourceType}/`));
return totalExportedCount;
}
catch (error) {
throw new SoloError(`Failed to export ${resourceType}: ${error.message}`, error);
}
}
async waitForConsensusPods() {
const namespace = this.remoteConfig.getNamespace();
const consensusNodes = this.remoteConfig.getConsensusNodes();
for (const consensusNode of consensusNodes) {
const context = helpers.extractContextFromConsensusNodes(consensusNode.name, consensusNodes);
const k8 = this.k8Factory.getK8(context);
this.logger.info(`Waiting for pod of node ${consensusNode.name} in namespace ${namespace.toString()} (context: ${context})`);
await k8
.pods()
.waitForRunningPhase(namespace, [`solo.hedera.com/node-name=${consensusNode.name}`, 'solo.hedera.com/type=network-node'], constants.PODS_RUNNING_MAX_ATTEMPTS, constants.PODS_RUNNING_DELAY);
}
}
/**
* Export all configmaps from the cluster as YAML files
* @param outputDirectory - directory to export configmaps to
* @returns number of configmaps exported
*/
async exportConfigMaps(outputDirectory) {
return this.exportResources(outputDirectory, 'configmaps');
}
/**
* Export all secrets from the cluster as YAML files
* @param outputDirectory - directory to export secrets to
* @returns number of secrets exported
*/
async exportSecrets(outputDirectory) {
return this.exportResources(outputDirectory, 'secrets');
}
/**
* Backup all component configurations
*/
async backup(argv) {
// Load configurations
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
const outputDirectory = this.configManager.getFlag(flags.outputDir) || './solo-backup';
const quiet = this.configManager.getFlag(flags.quiet);
// Get namespace, contexts, and cluster references for backup operations
const namespace = this.remoteConfig.getNamespace();
const clusterReferences = this.remoteConfig.getClusterRefs();
const consensusNodes = this.remoteConfig.getConsensusNodes();
// Note: Network should be frozen before backup
// Run: solo consensus network freeze --deployment <deployment-name>
this.logger.showUser(chalk.yellow('\n⚠️ Recommendation: Freeze the network before backup for data consistency.\n' +
` Run: solo consensus network freeze --deployment ${this.configManager.getFlag(flags.deployment)}\n`));
const tasks = new Listr([
{
title: 'Export ConfigMaps',
task: async (context_, task) => {
context_.configMapCount = await this.exportConfigMaps(outputDirectory);
task.title = `Export ConfigMaps: ${context_.configMapCount} exported`;
},
},
{
title: 'Export Secrets',
task: async (context_, task) => {
context_.secretCount = await this.exportSecrets(outputDirectory);
task.title = `Export Secrets: ${context_.secretCount} exported`;
},
},
{
title: 'Download Node Logs',
task: async (context_, task) => {
const networkNodes = container.resolve(InjectTokens.NetworkNodes);
for (const [clusterReference, context] of clusterReferences.entries()) {
const logsDirectory = PathEx.join(outputDirectory, clusterReference, 'logs');
await networkNodes.getLogs(namespace, [context], logsDirectory);
}
task.title = `Download Node Logs: ${clusterReferences.size} cluster(s) completed`;
},
},
{
title: 'Download Node State Files',
task: async (context_, task) => {
const networkNodes = container.resolve(InjectTokens.NetworkNodes);
for (const node of consensusNodes) {
const nodeAlias = node.name;
const context = helpers.extractContextFromConsensusNodes(nodeAlias, consensusNodes);
const clusterReference = node.cluster; // Get cluster ref from node metadata
const statesDirectory = PathEx.join(outputDirectory, 'states', clusterReference);
await networkNodes.getStatesFromPod(namespace, nodeAlias, context, statesDirectory);
}
task.title = `Download Node State Files: ${consensusNodes.length} node(s) completed`;
},
},
{
title: 'Compress backup directory',
skip: () => {
const zipPassword = this.configManager.getFlag(flags.zipPassword);
return !zipPassword;
},
task: async () => {
const zipPassword = this.configManager.getFlag(flags.zipPassword);
const zipFile = this.configManager.getFlag(flags.zipFile);
const compressionCommand = `cd "${outputDirectory}" && zip -rX -P "${zipPassword}" "${zipFile}" .`;
const shellRunner = new ShellRunner(this.logger);
await shellRunner.run(compressionCommand, [], true, false);
this.logger.showUser(chalk.green(`Backup compressed to ${zipFile}`));
},
},
], constants.LISTR_DEFAULT_OPTIONS.DEFAULT);
try {
const context_ = await tasks.run();
if (!quiet) {
this.logger.showUser('');
this.logger.showUser(chalk.green(`✅ Backup completed: ${context_.configMapCount} configmap(s) and ${context_.secretCount} secret(s) exported`));
}
}
catch (error) {
this.logger.showUser(chalk.red(`❌ Error during backup: ${error.message}`));
throw error;
}
return true;
}
/**
* Generic import function for Kubernetes resources from multiple clusters
* @param inputDirectory - directory to import resources from
* @param resourceType - type of resource ('configmaps' or 'secrets')
* @returns total number of resources imported across all clusters
*/
async importResources(inputDirectory, resourceType) {
try {
const namespace = this.remoteConfig.getNamespace();
const clusterReferences = this.remoteConfig.getClusterRefs();
this.logger.showUser(chalk.cyan(`\nImporting ${resourceType} to namespace: ${namespace.toString()} across ${clusterReferences.size} cluster(s)`));
let totalImportedCount = 0;
// Iterate through each cluster
for (const [clusterReference, context] of clusterReferences.entries()) {
this.logger.showUser(chalk.cyan(`\n Processing cluster: ${clusterReference} (context: ${context})`));
const k8 = this.k8Factory.getK8(context);
const contextDirectory = PathEx.join(inputDirectory, clusterReference, resourceType);
// Check if directory exists
if (!fs.existsSync(contextDirectory)) {
this.logger.showUser(chalk.yellow(` No ${resourceType} directory found for context: ${context}`));
continue;
}
// Read all YAML files in the directory
const files = fs
.readdirSync(contextDirectory)
.filter((file) => file.endsWith('.yaml'));
if (files.length === 0) {
this.logger.showUser(chalk.yellow(` No ${resourceType} YAML files found in this cluster`));
continue;
}
this.logger.showUser(chalk.white(` Found ${files.length} ${resourceType} file(s)`));
// Import each resource from YAML
for (const file of files) {
const filePath = PathEx.join(contextDirectory, file);
const yamlContent = fs.readFileSync(filePath, 'utf8');
const resource = yaml.parse(yamlContent);
try {
// skip configMap file SOLO_REMOTE_CONFIGMAP_NAME
if (resource.metadata.name === constants.SOLO_REMOTE_CONFIGMAP_NAME) {
this.logger.showUser(chalk.yellow(` Skipping ${resourceType} file: ${resource.metadata.name}`));
continue;
}
await (resourceType === 'configmaps'
? k8
.configMaps()
.createOrReplace(namespace, resource.metadata.name, resource.metadata.labels || {}, resource.data || {})
: k8
.secrets()
.createOrReplace(namespace, resource.metadata.name, resource.type || 'Opaque', resource.data || {}, resource.metadata.labels || {}));
this.logger.showUser(chalk.gray(` ✓ Imported: ${resource.metadata.name}`));
totalImportedCount++;
}
catch (error) {
this.logger.showUser(chalk.red(` ✗ Failed to import ${file}: ${error.message}`));
}
}
this.logger.showUser(chalk.green(` ✓ Imported ${resourceType} to context: ${context}`));
}
this.logger.showUser(chalk.green(`\n✓ Total imported: ${totalImportedCount} ${resourceType} to ${clusterReferences.size} cluster(s)`));
return totalImportedCount;
}
catch (error) {
throw new SoloError(`Failed to import ${resourceType}: ${error.message}`, error);
}
}
/**
* Import all configmaps to the cluster from YAML files
* @param inputDirectory - directory to import configmaps from
* @returns number of configmaps imported
*/
async importConfigMaps(inputDirectory) {
return this.importResources(inputDirectory, 'configmaps');
}
/**
* Import all secrets to the cluster from YAML files
* @param inputDirectory - directory to import secrets from
* @returns number of secrets imported
*/
async importSecrets(inputDirectory) {
return this.importResources(inputDirectory, 'secrets');
}
/**
* Restore logs and configs to consensus nodes
* @param inputDirectory - directory containing logs
* @returns Promise that resolves when restoration is complete
*/
async restoreLogsAndConfigs(inputDirectory) {
const namespace = this.remoteConfig.getNamespace();
const clusterReferences = this.remoteConfig.getClusterRefs();
for (const [clusterReference, context] of clusterReferences.entries()) {
const logsDirectory = PathEx.join(inputDirectory, clusterReference, 'logs', namespace.toString());
// Check if logs directory exists
if (!fs.existsSync(logsDirectory)) {
this.logger.showUser(chalk.yellow(` No logs directory found for context: ${context}`));
continue;
}
// Get all log zip files directly from logs directory
const allFiles = fs.readdirSync(logsDirectory);
this.logger.showUser(`Files are found in ${logsDirectory} are : ${allFiles.join(', ')}`);
const logFiles = allFiles.filter((file) => file.endsWith(constants.LOG_CONFIG_ZIP_SUFFIX));
if (logFiles.length === 0) {
this.logger.showUser(chalk.red(` No log files found in context: ${context} (found ${allFiles.length} file(s))`));
this.logger.showUser(chalk.gray(` Available files: ${allFiles.join(', ')}`));
throw new SoloError(`No log files found to restore in context: ${context}`);
}
this.logger.showUser(chalk.white(` Restoring ${logFiles.length} log file(s) to context: ${context}`));
// Get all pods in this context
const k8 = this.k8Factory.getK8(context);
const pods = await k8.pods().list(namespace, ['solo.hedera.com/type=network-node']);
// Upload logs to each pod
for (const logFile of logFiles) {
// Extract pod name from log file by removing the suffix
const podName = logFile.replace(constants.LOG_CONFIG_ZIP_SUFFIX, '');
const pod = pods.find((p) => p.podReference.name.name === podName);
if (!pod) {
this.logger.showUser(chalk.yellow(` No matching pod found for log file: ${logFile}`));
continue;
}
const logFilePath = PathEx.join(logsDirectory, logFile);
const podReference = pod.podReference;
const containerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER);
const container = k8.containers().readByRef(containerReference);
// Upload zipped log file to pod
this.logger.showUser(chalk.gray(` Uploading log file: ${logFile}`));
await container.copyTo(logFilePath, `${constants.HEDERA_HAPI_PATH}`);
// Wait for file to sync to the file system
await helpers.sleep(Duration.ofSeconds(2));
// Unzip the log file
this.logger.showUser(chalk.gray(` Extracting log file in pod: ${podName}`));
await container.execContainer([
'unzip',
'-o',
`${constants.HEDERA_HAPI_PATH}/${logFile}`,
'-d',
`${constants.HEDERA_HAPI_PATH}`,
]);
// Fix ownership of extracted files to hedera user
this.logger.showUser(chalk.gray(` Setting ownership for extracted files in pod: ${podName}`));
await container.execContainer(['bash', '-c', `chown -R hedera:hedera ${constants.HEDERA_HAPI_PATH}`]);
this.logger.showUser(chalk.green(` ✓ Restored log for pod: ${podName}`));
}
}
}
/**
* Restore all component configurations
* Command: solo config ops restore-config
*/
async restoreConfig(argv) {
// Load configurations
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
const inputDirectory = this.configManager.getFlag(flags.inputDir) || './solo-backup';
const quiet = this.configManager.getFlag(flags.quiet);
// Get configuration data
const namespace = this.remoteConfig.getNamespace();
const consensusNodes = this.remoteConfig.getConsensusNodes();
const nodeAliases = consensusNodes.map((node) => node.name);
const tasks = new Listr([
{
title: 'Initialize restore configuration',
task: async (context_, task) => {
// Build pod references map
const podReferences = {};
for (const nodeAlias of nodeAliases) {
const context = helpers.extractContextFromConsensusNodes(nodeAlias, consensusNodes);
const k8 = this.k8Factory.getK8(context);
const pods = await k8
.pods()
.list(namespace, [`solo.hedera.com/node-name=${nodeAlias}`, 'solo.hedera.com/type=network-node']);
if (pods.length > 0) {
podReferences[nodeAlias] = pods[0].podReference;
}
}
// Initialize config object expected by uploadStateFiles
context_.config = {
namespace,
consensusNodes,
nodeAliases,
podRefs: podReferences,
stateFile: inputDirectory, // Not used since we pass stateFileDirectory
};
task.title = 'Initialize restore configuration: completed';
},
},
{
title: 'Freeze network (if running)',
task: async (context_, task) => {
try {
// Use the existing freeze command to freeze the network
await invokeSoloCommand('Freeze network', 'consensus network freeze', () => {
const argv = CommandHelpers.newArgv();
argv.push('consensus', 'network', 'freeze', '--deployment', context_.deployment);
return argv;
}, this.taskList).task(context_, task);
task.title = 'Freeze network: completed';
}
catch (error) {
// Network is not running or already frozen, which is fine for restore
this.logger.info(`Network freeze skipped: ${error.message}`);
task.title = 'Freeze network: skipped (network not running)';
}
},
},
{
title: 'Import ConfigMaps',
task: async (context_, task) => {
context_.configMapCount = await this.importConfigMaps(inputDirectory);
task.title = `Import ConfigMaps: ${context_.configMapCount} imported`;
},
},
{
title: 'Import Secrets',
task: async (context_, task) => {
context_.secretCount = await this.importSecrets(inputDirectory);
task.title = `Import Secrets: ${context_.secretCount} imported`;
},
},
{
title: 'Wait for consensus node pods',
task: async (context_, task) => {
await this.waitForConsensusPods();
task.title = 'Wait for consensus node pods: completed';
},
},
{
title: 'Restore Logs and Configs',
task: async (context_, task) => {
await this.restoreLogsAndConfigs(inputDirectory);
task.title = 'Restore Logs and Configs: completed';
},
},
this.nodeCommandTasks.uploadStateFiles(false, inputDirectory),
], constants.LISTR_DEFAULT_OPTIONS.DEFAULT);
try {
const context_ = await tasks.run();
if (!quiet) {
this.logger.showUser('');
this.logger.showUser(chalk.green(`✅ Restore completed: ${context_.configMapCount} configmap(s) and ${context_.secretCount} secret(s) imported`));
}
}
catch (error) {
this.logger.showUser(chalk.red(`❌ Error during restore: ${error.message}`));
throw error;
}
return true;
}
/**
* Read the remote config from a local YAML file
*/
async readRemoteConfigFile(configFilePath) {
this.logger.showUser(chalk.cyan(`Reading remote config from file: ${configFilePath}`));
try {
// Check if file exists
if (!fs.existsSync(configFilePath)) {
throw new SoloError(`Config file not found: ${configFilePath}`);
}
// Read file content
const fileContent = fs.readFileSync(configFilePath, 'utf8');
// Parse YAML
const configData = yaml.parse(fileContent);
if (!configData) {
throw new SoloError('Config file is empty or invalid');
}
this.logger.showUser(chalk.green('✓ Read config file successfully'));
return configData;
}
catch (error) {
throw new SoloError(`Failed to read config file ${configFilePath}: ${error.message}`, error);
}
}
/**
* Parse the config data and instantiate RemoteConfig object
*/
parseRemoteConfig(configData) {
this.logger.showUser(chalk.cyan('Parsing remote configuration...'));
try {
let actualConfigData = configData;
// Check if this is a ConfigMap wrapper (has apiVersion, kind, data)
if (configData.kind === 'ConfigMap' && configData.data) {
this.logger.showUser(chalk.gray(' Detected ConfigMap format, extracting remote config data...'));
// Extract the remote config from the ConfigMap data field
const remoteConfigKey = 'remote-config-data';
const remoteConfigYaml = configData.data[remoteConfigKey];
if (!remoteConfigYaml) {
throw new SoloError(`ConfigMap does not contain '${remoteConfigKey}' key`);
}
// Parse the YAML string to get the actual config object
actualConfigData = yaml.parse(remoteConfigYaml);
this.logger.showUser(chalk.gray(' ✓ Extracted remote config from ConfigMap'));
}
// Transform to RemoteConfigSchema instance
const remoteConfigSchema = plainToInstance(RemoteConfigSchema, actualConfigData, {
excludeExtraneousValues: true,
});
const remoteConfig = new RemoteConfig(remoteConfigSchema);
this.logger.showUser(chalk.green('✓ Remote configuration parsed successfully'));
return remoteConfig;
}
catch (error) {
throw new SoloError(`Failed to parse remote config: ${error.message}`, error);
}
}
buildDeploymentTasks() {
const tasks = [];
return [
...tasks,
// Keys generation task
{
title: 'Generate consensus node keys',
skip: (context_) => !context_.deploymentState?.consensusNodes || context_.deploymentState.consensusNodes.length === 0,
task: async (context_, taskListWrapper) => {
return CommandHelpers.subTaskSoloCommand(KeysCommandDefinition.KEYS_COMMAND, taskListWrapper, () => {
const argv = CommandHelpers.newArgv();
argv.push(...KeysCommandDefinition.KEYS_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.generateGossipKeys), CommandHelpers.optionFromFlag(flags.generateTlsKeys), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, CommandHelpers.optionFromFlag(flags.nodeAliasesUnparsed), context_.nodeAliases);
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
},
// Consensus network deploy task
{
title: 'Deploy consensus network',
skip: (context_) => !context_.deploymentState?.consensusNodes || context_.deploymentState.consensusNodes.length === 0,
task: async (context_, taskListWrapper) => {
return CommandHelpers.subTaskSoloCommand(ConsensusCommandDefinition.DEPLOY_COMMAND, taskListWrapper, () => {
const argv = CommandHelpers.newArgv();
// Use options from options file if provided, otherwise use default
if (context_.componentOptions?.consensus) {
// Add command name first
argv.push(...ConsensusCommandDefinition.DEPLOY_COMMAND.split(' '), ...context_.componentOptions.consensus);
}
else {
// Default behavior
argv.push(...ConsensusCommandDefinition.DEPLOY_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, CommandHelpers.optionFromFlag(flags.persistentVolumeClaims));
// Enable load balancer if multiple clusters are detected
if (context_.clusters && context_.clusters.length > 1) {
argv.push(CommandHelpers.optionFromFlag(flags.loadBalancerEnabled));
this.logger.info(`Multiple clusters detected (${context_.clusters.length}), enabling load balancer`);
}
if (context_.versions?.consensusNode) {
argv.push(CommandHelpers.optionFromFlag(flags.releaseTag), context_.versions.consensusNode.toString());
}
if (context_.versions?.chart) {
argv.push(CommandHelpers.optionFromFlag(flags.soloChartVersion), context_.versions.chart.toString());
}
}
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
},
// Block nodes deploy tasks (one per block node)
...this.buildBlockNodeTasks(),
// Consensus node setup task
{
title: 'Setup consensus nodes',
skip: (context_) => !context_.deploymentState?.consensusNodes || context_.deploymentState.consensusNodes.length === 0,
task: async (context_, taskListWrapper) => {
return CommandHelpers.subTaskSoloCommand(ConsensusCommandDefinition.SETUP_COMMAND, taskListWrapper, () => {
const argv = CommandHelpers.newArgv();
argv.push(...ConsensusCommandDefinition.SETUP_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.nodeAliasesUnparsed), context_.nodeAliases, CommandHelpers.optionFromFlag(flags.deployment), context_.deployment);
if (context_.versions?.consensusNode) {
argv.push(CommandHelpers.optionFromFlag(flags.releaseTag), context_.versions.consensusNode.toString());
}
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
},
// Consensus node start task
{
title: 'Start consensus nodes',
skip: (context_) => !context_.deploymentState?.consensusNodes || context_.deploymentState.consensusNodes.length === 0,
task: async (context_, taskListWrapper) => {
return CommandHelpers.subTaskSoloCommand(ConsensusCommandDefinition.START_COMMAND, taskListWrapper, () => {
const argv = CommandHelpers.newArgv();
argv.push(...ConsensusCommandDefinition.START_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, CommandHelpers.optionFromFlag(flags.nodeAliasesUnparsed), context_.nodeAliases);
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
},
...this.buildMirrorNodeTasks(),
...this.buildRelayNodeTasks(),
...this.buildExplorerTasks(),
];
}
/**
* Build block node deployment tasks
*/
buildBlockNodeTasks() {
return [
{
title: 'Deploy block nodes',
skip: (context_) => !context_.deploymentState?.blockNodes || context_.deploymentState.blockNodes.length === 0,
task: async (context_, taskListWrapper) => {
const blockNodeTasks = [];
for (const blockNode of context_.deploymentState.blockNodes) {
blockNodeTasks.push({
title: `Deploy block node ${blockNode.metadata.id}`,
task: async (_, subTaskListWrapper) => {
// Switch to the correct cluster context for this block node
const clusterReference = blockNode.metadata.cluster;
if (blockNode.metadata.context) {
this.logger.info(`Switching to cluster '${blockNode.metadata.context}' for block node ${blockNode.metadata.id}`);
const k8 = this.k8Factory.getK8(blockNode.metadata.context);
k8.contexts().updateCurrent(blockNode.metadata.context);
}
return subTaskSoloCommand(BlockCommandDefinition.ADD_COMMAND, subTaskListWrapper, () => {
const argv = CommandHelpers.newArgv();
// Use options from options file if provided, otherwise use default
if (context_.componentOptions?.block) {
// Add command name first
argv.push(...BlockCommandDefinition.ADD_COMMAND.split(' '), ...context_.componentOptions.block);
}
else {
// Default behavior
argv.push(...BlockCommandDefinition.ADD_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, optionFromFlag(flags.clusterRef), clusterReference);
if (context_.versions?.blockNodeChart) {
argv.push(optionFromFlag(flags.blockNodeChartVersion), context_.versions.blockNodeChart.toString());
}
}
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
});
}
return taskListWrapper.newListr(blockNodeTasks, {
concurrent: false,
rendererOptions: { collapseSubtasks: false },
});
},
},
];
}
/**
* Build mirror node deployment tasks
*/
buildMirrorNodeTasks() {
return [
{
title: 'Deploy mirror nodes',
skip: (context_) => !context_.deploymentState?.mirrorNodes || context_.deploymentState.mirrorNodes.length === 0,
task: async (context_, taskListWrapper) => {
const mirrorNodeTasks = [];
for (const mirrorNode of context_.deploymentState.mirrorNodes) {
mirrorNodeTasks.push({
title: `Deploy mirror node ${mirrorNode.metadata.id}`,
task: async (_, subTaskListWrapper) => {
// Switch to the correct cluster context for this mirror node
const clusterReference = mirrorNode.metadata.cluster;
if (mirrorNode.metadata.context) {
this.logger.info(`Switching to cluster '${mirrorNode.metadata.context}' for mirror node ${mirrorNode.metadata.id}`);
const k8 = this.k8Factory.getK8(mirrorNode.metadata.context);
k8.contexts().updateCurrent(mirrorNode.metadata.context);
}
return subTaskSoloCommand(MirrorCommandDefinition.ADD_COMMAND, subTaskListWrapper, () => {
const argv = CommandHelpers.newArgv();
// Use options from options file if provided, otherwise use default
if (context_.componentOptions?.mirror) {
// Add command name first
argv.push(...MirrorCommandDefinition.ADD_COMMAND.split(' '), ...context_.componentOptions.mirror);
}
else {
// Default behavior
argv.push(...MirrorCommandDefinition.ADD_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, optionFromFlag(flags.clusterRef), clusterReference);
if (context_.versions?.mirrorNodeChart) {
argv.push(optionFromFlag(flags.mirrorNodeVersion), context_.versions.mirrorNodeChart.toString());
}
}
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
});
}
return taskListWrapper.newListr(mirrorNodeTasks, {
concurrent: false,
rendererOptions: { collapseSubtasks: false },
});
},
},
];
}
/**
* Build relay node deployment tasks
*/
buildRelayNodeTasks() {
return [
{
title: 'Deploy relay nodes',
skip: (context_) => !context_.deploymentState?.relayNodes || context_.deploymentState.relayNodes.length === 0,
task: async (context_, taskListWrapper) => {
const relayNodeTasks = [];
for (const relayNode of context_.deploymentState.relayNodes) {
relayNodeTasks.push({
title: `Deploy relay node ${relayNode.metadata.id}`,
task: async (_, subTaskListWrapper) => {
// Switch to the correct cluster context for this relay node
const clusterReference = relayNode.metadata.cluster;
if (relayNode.metadata.context) {
this.logger.info(`Switching to cluster '${relayNode.metadata.context}' for relay node ${relayNode.metadata.id}`);
const k8 = this.k8Factory.getK8(relayNode.metadata.context);
k8.contexts().updateCurrent(relayNode.metadata.context);
}
return subTaskSoloCommand(RelayCommandDefinition.ADD_COMMAND, subTaskListWrapper, () => {
const argv = CommandHelpers.newArgv();
// Use options from options file if provided, otherwise use default
if (context_.componentOptions?.relay) {
// Add command name first
argv.push(...RelayCommandDefinition.ADD_COMMAND.split(' '), ...context_.componentOptions.relay);
}
else {
// Default behavior
argv.push(...RelayCommandDefinition.ADD_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, CommandHelpers.optionFromFlag(flags.nodeAliasesUnparsed), context_.nodeAliases);
// Add cluster ref if node has cluster metadata
if (clusterReference) {
argv.push(optionFromFlag(flags.clusterRef), clusterReference);
}
if (context_.versions?.jsonRpcRelayChart) {
argv.push(optionFromFlag(flags.relayReleaseTag), context_.versions.jsonRpcRelayChart.toString());
}
}
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
});
}
return taskListWrapper.newListr(relayNodeTasks, {
concurrent: false,
rendererOptions: { collapseSubtasks: false },
});
},
},
];
}
/**
* Build explorer deployment tasks
*/
buildExplorerTasks() {
return [
{
title: 'Deploy explorers',
skip: (context_) => !context_.deploymentState?.explorers || context_.deploymentState.explorers.length === 0,
task: async (context_, taskListWrapper) => {
const explorerTasks = [];
for (const explorer of context_.deploymentState.explorers) {
explorerTasks.push({
title: `Deploy explorer ${explorer.metadata.id}`,
task: async (_, subTaskListWrapper) => {
// Switch to the correct cluster context for this explorer
const clusterReference = explorer.metadata.cluster;
if (explorer.metadata.context) {
this.logger.info(`Switching to cluster '${explorer.metadata.context}' for explorer ${explorer.metadata.id}`);
const k8 = this.k8Factory.getK8(explorer.metadata.context);
k8.contexts().updateCurrent(explorer.metadata.context);
}
return subTaskSoloCommand(ExplorerCommandDefinition.ADD_COMMAND, subTaskListWrapper, () => {
const argv = CommandHelpers.newArgv();
// Use options from options file if provided, otherwise use default
if (context_.componentOptions?.explorer) {
// Add command name first
argv.push(...ExplorerCommandDefinition.ADD_COMMAND.split(' '), ...context_.componentOptions.explorer);
}
else {
// Default behavior
argv.push(...ExplorerCommandDefinition.ADD_COMMAND.split(' '), CommandHelpers.optionFromFlag(flags.deployment), context_.deployment, optionFromFlag(flags.clusterRef), clusterReference);
if (context_.versions?.explorerChart) {
argv.push(optionFromFlag(flags.explorerVersion), context_.versions.explorerChart.toString());
}
}
return CommandHelpers.argvPushGlobalFlags(argv);
}, this.taskList);
},
});
}
return taskListWrapper.newListr(explorerTasks, {
concurrent: false,
rendererOptions: { collapseSubtasks: false },
});
},
},
];
}
/**
* Build scan backup directory task
*/
buildScanBackupDirectoryTask() {
return {
title: 'Scan backup directory structure',
task: async (context_) => {
const inputDirectory = context_.inputDirectory;
// Verify input directory exists
if (!fs.existsSync(inputDirectory)) {
throw new SoloError(`Input directory does not exist: ${inputDirectory}`);
}
// Read subdirectories
const entries = fs.readdirSync(inputDirectory, { withFileTypes: true });
const clusterReferenceDirectories = entries