UNPKG

@hashgraph/solo

Version:

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

871 lines (870 loc) 73.6 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __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