UNPKG

@hashgraph/solo

Version:

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

898 lines (779 loc) 30.8 kB
// SPDX-License-Identifier: Apache-2.0 import fs, {type Stats} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {format} from 'node:util'; import {SoloError} from './errors/solo-error.js'; import {Templates} from './templates.js'; import * as constants from './constants.js'; import {PrivateKey, ServiceEndpoint, type Long} from '@hiero-ledger/sdk'; import {type AnyYargs, type AnyListrContext, type NodeAlias, type NodeAliases} from '../types/aliases.js'; import {type CommandFlag} from '../types/flag-types.js'; import {type SoloLogger} from './logging/solo-logger.js'; import {type Duration} from './time/duration.js'; import {type NodeAddConfigClass} from '../commands/node/config-interfaces/node-add-config-class.js'; import {type ConsensusNode} from './model/consensus-node.js'; import { type ClusterReferenceName, type ClusterReferences, type Optional, type ReleaseNameData, } from '../types/index.js'; import {NamespaceName} from '../types/namespace/namespace-name.js'; import {type K8Factory} from '../integration/kube/k8-factory.js'; import chalk from 'chalk'; import {PathEx} from '../business/utils/path-ex.js'; import {type ConfigManager} from './config-manager.js'; import {Flags, Flags as flags} from '../commands/flags.js'; import {type Realm, type Shard} from './../types/index.js'; import {execSync} from 'node:child_process'; import {type Pod} from '../integration/kube/resources/pod/pod.js'; import yaml from 'yaml'; import {type ConfigMap} from '../integration/kube/resources/config-map/config-map.js'; import {type K8} from '../integration/kube/k8.js'; import {BlockNodesJsonWrapper} from './block-nodes-json-wrapper.js'; import {K8Helper} from '../business/utils/k8-helper.js'; import {type Container} from '../integration/kube/resources/container/container.js'; import {SemanticVersion} from '../business/utils/semantic-version.js'; export function getInternalAddress( releaseVersion: SemanticVersion<string> | string, namespaceName: NamespaceName, nodeAlias: NodeAlias, ): string { return new SemanticVersion(releaseVersion).greaterThanOrEqual('0.58.5') ? '127.0.0.1' : Templates.renderFullyQualifiedNetworkPodName(namespaceName, nodeAlias); } export function sleep(duration: Duration): Promise<void> { return new Promise<void>((resolve: (value: PromiseLike<void> | void) => void): void => { setTimeout(resolve, duration.toMillis()); }); } export function parseNodeAliases( input: string, consensusNodes?: ConsensusNode[], configManager?: ConfigManager, ): NodeAliases { let nodeAliases: NodeAlias[] = splitFlagInput(input, ',') as NodeAliases; if (nodeAliases.length === 0) { nodeAliases = consensusNodes?.map((node: {name: string}): NodeAlias => { return node.name as NodeAlias; }); configManager?.setFlag(flags.nodeAliasesUnparsed, nodeAliases.join(',')); if (!nodeAliases || nodeAliases.length === 0) { return []; } } return nodeAliases; } export function splitFlagInput(input: string, separator: string = ','): string[] { if (!input) { return []; } else if (typeof input !== 'string') { throw new SoloError(`input [input='${input}'] is not a comma separated string`); } return input .split(separator) .map((s): string => s.trim()) .filter(Boolean); } /** * @param arr - The array to be cloned * @returns a new array with the same elements as the input array */ export function cloneArray<T>(array: T[]): T[] { return structuredClone(array); } export function getTemporaryDirectory(): string { return fs.mkdtempSync(PathEx.join(os.tmpdir(), 'solo-')); } export function createBackupDirectory( destinationDirectory: string, prefix: string = 'backup', currentDate: Date = new Date(), ): string { const dateDirectory: string = format( '%s%s%s_%s%s%s', currentDate.getFullYear(), currentDate.getMonth().toString().padStart(2, '0'), currentDate.getDate().toString().padStart(2, '0'), currentDate.getHours().toString().padStart(2, '0'), currentDate.getMinutes().toString().padStart(2, '0'), currentDate.getSeconds().toString().padStart(2, '0'), ); const backupDirectory: string = PathEx.join(destinationDirectory, prefix, dateDirectory); if (!fs.existsSync(backupDirectory)) { fs.mkdirSync(backupDirectory, {recursive: true}); } return backupDirectory; } export function makeBackup(fileMap: Map<string, string> = new Map<string, string>(), removeOld: boolean = true): void { for (const entry of fileMap) { const sourcePath: string = entry[0]; const destinationPath: string = entry[1]; if (fs.existsSync(sourcePath)) { fs.cpSync(sourcePath, destinationPath); if (removeOld) { fs.rmSync(sourcePath); } } } } export function backupOldTlsKeys( nodeAliases: NodeAliases, keysDirectory: string, currentDate: Date = new Date(), directoryPrefix: string = 'tls', ): string { const backupDirectory: string = createBackupDirectory(keysDirectory, `unused-${directoryPrefix}`, currentDate); const fileMap: Map<string, string> = new Map<string, string>(); for (const nodeAlias of nodeAliases) { const sourcePath: string = PathEx.join(keysDirectory, Templates.renderTLSPemPrivateKeyFile(nodeAlias)); const destinationPath: string = PathEx.join(backupDirectory, Templates.renderTLSPemPrivateKeyFile(nodeAlias)); fileMap.set(sourcePath, destinationPath); } makeBackup(fileMap, true); return backupDirectory; } export function backupOldPemKeys( nodeAliases: NodeAliases, keysDirectory: string, currentDate: Date = new Date(), directoryPrefix: string = 'gossip-pem', ): string { const backupDirectory: string = createBackupDirectory(keysDirectory, `unused-${directoryPrefix}`, currentDate); const fileMap: Map<string, string> = new Map<string, string>(); for (const nodeAlias of nodeAliases) { const sourcePath: string = PathEx.join(keysDirectory, Templates.renderGossipPemPrivateKeyFile(nodeAlias)); const destinationPath: string = PathEx.join(backupDirectory, Templates.renderGossipPemPrivateKeyFile(nodeAlias)); fileMap.set(sourcePath, destinationPath); } makeBackup(fileMap, true); return backupDirectory; } export function getEnvironmentValue(environmentVariableArray: string[], name: string): string { const kvPair: string = environmentVariableArray.find((v): boolean => v.startsWith(`${name}=`)); return kvPair ? kvPair.split('=')[1] : undefined; } export function parseIpAddressToUint8Array(ipAddress: string): Uint8Array<ArrayBuffer> { const parts: string[] = ipAddress.split('.'); const uint8Array: Uint8Array<ArrayBuffer> = new Uint8Array(4); for (let index: number = 0; index < 4; index++) { uint8Array[index] = Number.parseInt(parts[index], 10); } return uint8Array; } /** If the basename of the src did not match expected basename, rename it first, then copy to destination */ export function renameAndCopyFile( sourceFilePath: string, expectedBaseName: string, destinationDirectory: string, ): void { const sourceDirectory: string = path.dirname(sourceFilePath); if (path.basename(sourceFilePath) !== expectedBaseName) { fs.renameSync(sourceFilePath, PathEx.join(sourceDirectory, expectedBaseName)); } // copy public key and private key to key directory fs.copyFile( PathEx.joinWithRealPath(sourceDirectory, expectedBaseName), PathEx.join(destinationDirectory, expectedBaseName), (error): void => { if (error) { throw new SoloError(`Error copying file: ${error.message}`); } }, ); } /** * Append root.image registry/repository/tag settings for a given node path to a Helm values argument string. * @param valuesArgument - existing values argument string (may be empty) * @param nodePath - base node path, e.g. `hedera.nodes[0]` * @param registry - image registry * @param repository - image repository * @param tag - image tag * @returns updated values argument string */ export function addRootImageValues( valuesArgument: string | undefined, nodePath: string, registry: string, repository: string, tag: string, ): string { let updatedValuesArgument: string = valuesArgument ?? ''; updatedValuesArgument += ` --set "${nodePath}.root.image.registry=${registry}"`; updatedValuesArgument += ` --set "${nodePath}.root.image.tag=${tag}"`; updatedValuesArgument += ` --set "${nodePath}.root.image.repository=${repository}"`; return updatedValuesArgument; } /** * Returns an object that can be written to a file without data loss. * Contains fields needed for adding a new node through separate commands * @param ctx * @returns file writable object */ export function addSaveContextParser(context_: AnyListrContext): Record<string, string> { const exportedContext: Record<string, string> = {} as Record<string, string>; const config: NodeAddConfigClass = context_.config as NodeAddConfigClass; const exportedFields: string[] = ['tlsCertHash', 'upgradeZipHash', 'newNode']; exportedContext.signingCertDer = context_.signingCertDer.toString(); exportedContext.gossipEndpoints = context_.gossipEndpoints.map( (endpoint: unknown): `${string}:${string}` => `${(endpoint as ServiceEndpoint)._domainName}:${(endpoint as ServiceEndpoint)._port}`, ); exportedContext.grpcServiceEndpoints = context_.grpcServiceEndpoints.map( (endpoint: unknown): `${string}:${string}` => `${(endpoint as ServiceEndpoint)._domainName}:${(endpoint as ServiceEndpoint)._port}`, ); exportedContext.adminKey = context_.adminKey.toString(); // @ts-expect-error - existingNodeAliases may not be defined on config exportedContext.existingNodeAliases = config.existingNodeAliases; for (const property of exportedFields) { exportedContext[property] = context_[property]; } return exportedContext; } type AddLoadContext = AnyListrContext & { config: NodeAddConfigClass; signingCertDer: Uint8Array; gossipEndpoints: ServiceEndpoint[]; grpcServiceEndpoints: ServiceEndpoint[]; adminKey: PrivateKey; tlsCertHash: unknown; upgradeZipHash: unknown; newNode: unknown; }; type AddLoadContextData = { signingCertDer: string; gossipEndpoints: string[]; grpcServiceEndpoints: string[]; adminKey: string; newNode: {name: NodeAlias}; existingNodeAliases: NodeAliases; tlsCertHash: unknown; upgradeZipHash: unknown; }; /** * Initializes objects in the context from a provided string * Contains fields needed for adding a new node through separate commands * @param ctx - accumulator object * @param ctxData - data in string format * @returns file writable object */ export function addLoadContextParser(context_: AddLoadContext, contextData: AddLoadContextData): void { const config: NodeAddConfigClass = context_.config; context_.signingCertDer = new Uint8Array( contextData.signingCertDer.split(',').map((value: string): number => Number.parseInt(value, 10)), ); context_.gossipEndpoints = prepareEndpoints( context_.config.endpointType, contextData.gossipEndpoints, constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT, ); context_.grpcServiceEndpoints = prepareEndpoints( context_.config.endpointType, contextData.grpcServiceEndpoints, constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT, ); context_.adminKey = PrivateKey.fromStringED25519(contextData.adminKey); config.nodeAlias = contextData.newNode.name; config.existingNodeAliases = contextData.existingNodeAliases; config.allNodeAliases = [...config.existingNodeAliases, contextData.newNode.name]; config.newNodeAliases = [contextData.newNode.name]; const fieldsToImport: Array<'tlsCertHash' | 'upgradeZipHash' | 'newNode'> = [ 'tlsCertHash', 'upgradeZipHash', 'newNode', ]; for (const property of fieldsToImport) { context_[property] = contextData[property]; } } export function prepareEndpoints( endpointType: string, endpoints: string[], defaultPort: number | string, ): ServiceEndpoint[] { const returnValue: ServiceEndpoint[] = []; for (const endpoint of endpoints) { const parts: string[] = endpoint.split(':'); let url: string = ''; let port: number | string = defaultPort; if (parts.length === 2) { url = parts[0].trim(); port = +parts[1].trim(); } else if (parts.length === 1) { url = parts[0]; } else { throw new SoloError(`incorrect endpoint format. expected url:port, found ${endpoint}`); } if (endpointType.toUpperCase() === constants.ENDPOINT_TYPE_IP) { returnValue.push( new ServiceEndpoint({ port: +port, ipAddressV4: parseIpAddressToUint8Array(url), }), ); } else { returnValue.push( new ServiceEndpoint({ port: +port, domainName: url, }), ); } } return returnValue; } /** Adds all the types of flags as properties on the provided argv object */ export function addFlagsToArgv( argv: AnyYargs, flags: { required: CommandFlag[]; optional: CommandFlag[]; }, ): AnyYargs { argv.required = flags.required; argv.optional = flags.optional; return argv; } export function resolveValidJsonFilePath(filePath: string, defaultPath?: string): string { if (!filePath) { if (defaultPath) { return resolveValidJsonFilePath(defaultPath); } return ''; } const resolvedFilePath: string = PathEx.realPathSync(filePath); if (!fs.existsSync(resolvedFilePath)) { if (defaultPath) { return resolveValidJsonFilePath(defaultPath); } throw new SoloError(`File does not exist: ${filePath}`); } // If the file is empty (or size cannot be determined) then fallback on the default values const throttleInfo: Stats = fs.statSync(resolvedFilePath); if (throttleInfo.size === 0 && defaultPath) { return resolveValidJsonFilePath(defaultPath); } else if (throttleInfo.size === 0) { throw new SoloError(`File is empty: ${filePath}`); } try { // Ensure the file contains valid JSON data JSON.parse(fs.readFileSync(resolvedFilePath, 'utf8')); return resolvedFilePath; } catch { // Fallback to the default values if an error occurs due to invalid JSON data or unable to read the file size if (defaultPath) { return resolveValidJsonFilePath(defaultPath); } throw new SoloError(`Invalid JSON data in file: ${filePath}`); } } export function prepareValuesFiles(valuesFile: string): string { let valuesArgument: string = ''; if (valuesFile) { const valuesFiles: string[] = valuesFile.split(','); for (const vf of valuesFiles) { const vfp: string = PathEx.resolve(vf); valuesArgument += ` --values ${vfp}`; } } return valuesArgument; } export function populateHelmArguments(valuesMapping: Record<string, string | boolean | number>): string { let valuesArgument: string = ''; for (const [key, value] of Object.entries(valuesMapping)) { valuesArgument += ` --set ${key}=${value}`; } return valuesArgument; } /** * @param nodeAlias * @param consensusNodes * @returns context of the node */ export function extractContextFromConsensusNodes( nodeAlias: NodeAlias, consensusNodes: ConsensusNode[], ): Optional<string> { if (!consensusNodes) { return undefined; } if (consensusNodes.length === 0) { return undefined; } const consensusNode: ConsensusNode = consensusNodes.find((node): boolean => node.name === nodeAlias); return consensusNode ? consensusNode.context : undefined; } /** * Check if the namespace exists in the context of given consensus nodes * @param consensusNodes * @param k8Factory * @param namespace */ export async function checkNamespace( consensusNodes: ConsensusNode[], k8Factory: K8Factory, namespace: NamespaceName, ): Promise<void> { for (const consensusNode of consensusNodes) { const k8: K8 = k8Factory.getK8(consensusNode.context); if (!(await k8.namespaces().has(namespace))) { throw new SoloError(`namespace ${namespace} does not exist in context ${consensusNode.context}`); } } } /** * Show a banner with the chart name and version * @param logger * @param chartName The name of the chart * @param version The version of the chart * @param type The action that was performed such as 'Installed' or 'Upgraded' */ // TODO convert usages to leverage the logger.addMessageGroupMessage() export function showVersionBanner( logger: SoloLogger, chartName: string, version: string, type: 'Installed' | 'Upgraded' = 'Installed', ): void { logger.showUser(chalk.cyan(` - ${type} ${chartName} chart, version:`, chalk.yellow(version))); } /** * Check if the input is a valid IPv4 address * @param input * @returns true if the input is a valid IPv4 address, false otherwise */ export function isIpV4Address(input: string): boolean { const ipv4Regex: RegExp = /^(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)$/; return ipv4Regex.test(input); } /** * Convert an IPv4 address to a base64 string * @param ipv4 The IPv4 address to convert * @returns The base64 encoded string representation of the IPv4 address */ export function ipV4ToBase64(ipv4: string): string { // Split the IPv4 address into its octets const octets: number[] = ipv4.split('.').map((octet): number => { const number_: number = Number.parseInt(octet, 10); // eslint-disable-next-line unicorn/prefer-number-properties if (isNaN(number_) || number_ < 0 || number_ > 255) { throw new Error(`Invalid IPv4 address: ${ipv4}`); } return number_; }); if (octets.length !== 4) { throw new Error(`Invalid IPv4 address: ${ipv4}`); } // Convert the octets to a Uint8Array const uint8Array: Uint8Array<ArrayBuffer> = new Uint8Array(octets); // Base64 encode the byte array return btoa(String.fromCodePoint(...uint8Array)); } export function entityId(shard: Shard, realm: Realm, number: Long | number | string): string { return `${shard}.${realm}.${number}`; } export async function withTimeout<T>( promise: Promise<T>, duration: Duration, errorMessage: string = 'Timeout', ): Promise<T> { return Promise.race([promise, throwAfter(duration, errorMessage)]); } async function throwAfter(duration: Duration, message: string = 'Timeout'): Promise<never> { await sleep(duration); throw new SoloError(message); } /** * Checks if a Docker image with the given name and tag exists locally. * @param imageName The name of the Docker image (e.g., "block-node-server"). * @param imageTag The tag of the Docker image (e.g., "0.12.0"). * @returns True if the image exists, false otherwise. */ export function checkDockerImageExists(imageName: string, imageTag: string): boolean { const fullImageName: string = `${imageName}:${imageTag}`; try { // Execute the 'docker images' command and filter by the image name // The --format "{{.Repository}}:{{.Tag}}" ensures consistent output // We use grep to filter for the exact image:tag const command: string = `docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^${fullImageName}$"`; const output: string = execSync(command, {encoding: 'utf8', stdio: 'pipe'}); return output.trim() === fullImageName; } catch (error: unknown) { console.error(`Error checking Docker image ${fullImageName}:`, (error as Error).message); return false; } } export function createDirectoryIfNotExists(file: string): void { const directory: string = path.dirname(file); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, {recursive: true}); } } export async function findMinioOperator(context: string, k8: K8Factory): Promise<ReleaseNameData> { const minioTenantPod: Optional<Pod> = await k8 .getK8(context) .pods() .listForAllNamespaces(['app.kubernetes.io/name=operator', 'operator=leader']) .then((pods: Pod[]): Optional<Pod> => pods[0]); if (!minioTenantPod) { return { exists: false, releaseName: undefined, }; } return { exists: true, releaseName: minioTenantPod.labels?.['app.kubernetes.io/instance'], }; } export function remoteConfigsToDeploymentsTable(remoteConfigs: ConfigMap[]): string[] { const rows: string[] = []; if (remoteConfigs.length > 0) { rows.push('Namespace : deployment'); for (const remoteConfig of remoteConfigs) { const remoteConfigData: unknown = yaml.parse(remoteConfig.data?.['remote-config-data']); let clustersData: unknown = undefined; if (typeof remoteConfigData === 'object' && remoteConfigData !== null && 'clusters' in remoteConfigData) { clustersData = (remoteConfigData as Record<string, unknown>).clusters; } const clustersArray: unknown[] = []; if (Array.isArray(clustersData)) { clustersArray.push(...clustersData); } else if (typeof clustersData === 'object' && clustersData !== null) { clustersArray.push(...Object.values(clustersData)); } for (const clusterData of clustersArray) { if (typeof clusterData === 'object' && clusterData !== null && 'deployment' in clusterData) { const deployment: unknown = (clusterData as Record<string, unknown>).deployment; if (typeof deployment === 'string') { rows.push(`${remoteConfig.namespace.name} : ${deployment}`); } } } } } return rows; } /** * Prepare the values files map for each cluster * * Order of precedence: * 1. Chart's default values file (if chartDirectory is set) * 2. Profile values file * 3. User's values file * @param clusterReferences * @param valuesFileInput - the values file input string * @param chartDirectory - the chart directory * @param profileValuesFile - the profile values file full path */ export function prepareValuesFilesMap( clusterReferences: ClusterReferences, chartDirectory?: string, profileValuesFile?: string, valuesFileInput?: string, ): Record<ClusterReferenceName, string> { // initialize the map with an empty array for each cluster-ref const valuesFiles: Record<ClusterReferenceName, string> = { [Flags.KEY_COMMON]: '', }; for (const [clusterReference] of clusterReferences) { valuesFiles[clusterReference] = ''; } // add the chart's default values file for each cluster-ref if chartDirectory is set // this should be the first in the list of values files as it will be overridden by user's input if (chartDirectory) { const chartValuesFile: string = PathEx.join(chartDirectory, 'solo-deployment', 'values.yaml'); for (const clusterReference in valuesFiles) { valuesFiles[clusterReference] += ` --values ${chartValuesFile}`; } } if (profileValuesFile) { const parsed: Record<string, Array<string>> = Flags.parseValuesFilesInput(profileValuesFile); for (const [clusterReference, files] of Object.entries(parsed)) { let vf: string = ''; for (const file of files) { vf += ` --values ${file}`; } if (clusterReference === Flags.KEY_COMMON) { for (const [cf] of Object.entries(valuesFiles)) { valuesFiles[cf] += vf; } } else { valuesFiles[clusterReference] += vf; } } } if (valuesFileInput) { const parsed: Record<string, Array<string>> = Flags.parseValuesFilesInput(valuesFileInput); for (const [clusterReference, files] of Object.entries(parsed)) { let vf: string = ''; for (const file of files) { vf += ` --values ${file}`; } if (clusterReference === Flags.KEY_COMMON) { for (const [clusterReference_] of Object.entries(valuesFiles)) { valuesFiles[clusterReference_] += vf; } } else { valuesFiles[clusterReference] += vf; } } } if (Object.keys(valuesFiles).length > 1) { // delete the common key if there is another cluster to use delete valuesFiles[Flags.KEY_COMMON]; } return valuesFiles; } /** * Prepare the values files map for each cluster * * Order of precedence: * 1. Chart's default values file (if chartDirectory is set) * 2. Base values files (applied after chart defaults, before the generated profile values file) * 3. Profile values file * 4. User's values file * @param clusterReferences * @param chartDirectory - the chart directory * @param profileValuesFile - mapping of clusterRef to the profile values file full path * @param valuesFileInput - the values file input string * @param baseValuesFiles - optional list of values file paths inserted between chart defaults and profile values */ export function prepareValuesFilesMapMultipleCluster( clusterReferences: ClusterReferences, chartDirectory?: string, profileValuesFile?: Record<ClusterReferenceName, string>, valuesFileInput?: string, baseValuesFiles?: string[], ): Record<ClusterReferenceName, string> { // initialize the map with an empty array for each cluster-ref const valuesFiles: Record<ClusterReferenceName, string> = {[Flags.KEY_COMMON]: ''}; for (const [clusterReference] of clusterReferences) { valuesFiles[clusterReference] = ''; } // add the chart's default values file for each cluster-ref if chartDirectory is set // this should be the first in the list of values files as it will be overridden by user's input if (chartDirectory) { const chartValuesFile: string = PathEx.join(chartDirectory, 'solo-deployment', 'values.yaml'); for (const clusterReference in valuesFiles) { valuesFiles[clusterReference] += ` --values ${chartValuesFile}`; } } // add base values files (e.g. component defaults) after chart defaults but before profile values if (baseValuesFiles) { for (const file of baseValuesFiles) { for (const clusterReference in valuesFiles) { valuesFiles[clusterReference] += ` --values ${file}`; } } } if (profileValuesFile) { for (const [clusterReference, file] of Object.entries(profileValuesFile)) { const valuesArgument: string = ` --values ${file}`; if (clusterReference === Flags.KEY_COMMON) { for (const clusterReference_ of Object.keys(valuesFiles)) { valuesFiles[clusterReference_] += valuesArgument; } } else { valuesFiles[clusterReference] += valuesArgument; } } } if (valuesFileInput) { const parsed: Record<string, Array<string>> = Flags.parseValuesFilesInput(valuesFileInput); for (const [clusterReference, files] of Object.entries(parsed)) { let vf: string = ''; for (const file of files) { vf += ` --values ${file}`; } if (clusterReference === Flags.KEY_COMMON) { for (const [clusterReference_] of Object.entries(valuesFiles)) { valuesFiles[clusterReference_] += vf; } } else { valuesFiles[clusterReference] += vf; } } } if (Object.keys(valuesFiles).length > 1) { // delete the common key if there is another cluster to use delete valuesFiles[Flags.KEY_COMMON]; } return valuesFiles; } /** * @param consensusNode - the targeted consensus node * @param logger * @param k8Factory */ export async function createAndCopyBlockNodeJsonFileForConsensusNode( consensusNode: ConsensusNode, logger: SoloLogger, k8Factory: K8Factory, ): Promise<void> { const { nodeId, context, name: nodeAlias, blockNodeMap, externalBlockNodeMap, namespace: namespaceNameAsString, } = consensusNode; const namespace: NamespaceName = NamespaceName.of(namespaceNameAsString); const blockNodesJsonData: string = new BlockNodesJsonWrapper(blockNodeMap, externalBlockNodeMap).toJSON(); const blockNodesJsonFilename: string = `${constants.BLOCK_NODES_JSON_FILE.replace('.json', '')}-${nodeId}.json`; const blockNodesJsonPath: string = PathEx.join(constants.SOLO_CACHE_DIR, blockNodesJsonFilename); fs.writeFileSync(blockNodesJsonPath, JSON.stringify(JSON.parse(blockNodesJsonData), undefined, 2)); // Check if the file exists before copying if (!fs.existsSync(blockNodesJsonPath)) { logger.warn(`Block nodes JSON file not found: ${blockNodesJsonPath}`); return; } const k8: K8 = k8Factory.getK8(context); const container: Container = await new K8Helper(context).getConsensusNodeRootContainer(namespace, nodeAlias); await container.execContainer('pwd'); const targetDirectory: string = `${constants.HEDERA_HAPI_PATH}/data/config`; await container.execContainer(`mkdir -p ${targetDirectory}`); // Copy the file and rename it to block-nodes.json in the destination await container.copyTo(blockNodesJsonPath, targetDirectory); // If using node-specific files, rename the copied file to the standard name const sourceFilename: string = path.basename(blockNodesJsonPath); await container.execContainer( `mv ${targetDirectory}/${sourceFilename} ${targetDirectory}/${constants.BLOCK_NODES_JSON_FILE}`, ); const applicationPropertiesFilePath: string = `${constants.HEDERA_HAPI_PATH}/data/config/${constants.APPLICATION_PROPERTIES}`; const applicationPropertiesData: string = await container.execContainer(`cat ${applicationPropertiesFilePath}`); const lines: string[] = applicationPropertiesData.split('\n'); // Remove line to enable overriding below. for (const line of lines) { if (line === 'blockStream.streamMode=RECORDS') { lines.splice(lines.indexOf(line), 1); } } // Switch to block streaming. if (!lines.some((line): boolean => line.startsWith('blockStream.streamMode='))) { lines.push(`blockStream.streamMode=${constants.BLOCK_STREAM_STREAM_MODE}`); } if (!lines.some((line): boolean => line.startsWith('blockStream.writerMode='))) { lines.push(`blockStream.writerMode=${constants.BLOCK_STREAM_WRITER_MODE}`); } await k8.configMaps().update(namespace, 'network-node-data-config-cm', { [constants.APPLICATION_PROPERTIES]: lines.join('\n'), }); const configName: string = `network-${nodeAlias}-data-config-cm`; const configMapExists: boolean = await k8.configMaps().exists(namespace, configName); await (configMapExists ? k8.configMaps().update(namespace, configName, {'block-nodes.json': blockNodesJsonData}) : k8.configMaps().create(namespace, configName, {}, {'block-nodes.json': blockNodesJsonData})); logger.debug(`Copied block-nodes configuration to consensus node ${consensusNode.name}`); const updatedApplicationPropertiesFilePath: string = PathEx.join( constants.SOLO_CACHE_DIR, constants.APPLICATION_PROPERTIES, ); fs.writeFileSync(updatedApplicationPropertiesFilePath, lines.join('\n')); await container.copyTo(updatedApplicationPropertiesFilePath, targetDirectory); }