UNPKG

@hashgraph/solo

Version:

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

672 lines 36.4 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); } }; import fs from 'node:fs'; import path from 'node:path'; import { SoloError } from './errors/solo-error.js'; import { IllegalArgumentError } from './errors/illegal-argument-error.js'; import { MissingArgumentError } from './errors/missing-argument-error.js'; import * as yaml from 'yaml'; import dot from 'dot-object'; import { readFile, writeFile } from 'node:fs/promises'; import { Flags as flags } from '../commands/flags.js'; import { Templates } from './templates.js'; import * as constants from './constants.js'; import * as helpers from './helpers.js'; import { inject, injectable } from 'tsyringe-neo'; import { patchInject } from './dependency-injection/container-helper.js'; import { InjectTokens } from './dependency-injection/inject-tokens.js'; import { ContainerReference } from '../integration/kube/resources/container/container-reference.js'; import { PathEx } from '../business/utils/path-ex.js'; import { AccountManager } from './account-manager.js'; import { LocalConfigRuntimeState } from '../business/runtime-state/config/local/local-config-runtime-state.js'; import { BlockNodesJsonWrapper } from './block-nodes-json-wrapper.js'; import { NamespaceName } from '../types/namespace/namespace-name.js'; import { Address } from '../business/address/address.js'; import * as versions from '../../version.js'; import { Numbers } from '../business/utils/numbers.js'; import { SemanticVersion } from '../business/utils/semantic-version.js'; let ProfileManager = class ProfileManager { logger; configManager; cacheDir; k8Factory; remoteConfig; accountManager; localConfig; constructor(logger, configManager, cacheDirectory, k8Factory, remoteConfig, accountManager, localConfig) { this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name); this.configManager = patchInject(configManager, InjectTokens.ConfigManager, this.constructor.name); this.cacheDir = PathEx.resolve(patchInject(cacheDirectory, InjectTokens.CacheDir, this.constructor.name)); this.k8Factory = patchInject(k8Factory, InjectTokens.K8Factory, this.constructor.name); this.remoteConfig = patchInject(remoteConfig, InjectTokens.RemoteConfigRuntimeState, this.constructor.name); this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name); this.localConfig = patchInject(localConfig, InjectTokens.LocalConfigRuntimeState, this.constructor.name); } /** * Set value in the YAML object * @param itemPath - item path in the yaml * @param value - value to be set * @param yamlRoot - root of the YAML object * @returns */ _setValue(itemPath, value, yamlRoot) { // find the location where to set the value in the YAML const itemPathParts = itemPath.split('.'); let parent = yamlRoot; let current = parent; let previousItemPath = ''; for (const itemPathPart of itemPathParts) { if (Numbers.isNumeric(itemPathPart)) { const itemPathIndex = Number.parseInt(itemPathPart, 10); // numeric path part can only be array index if (!Array.isArray(parent[previousItemPath])) { parent[previousItemPath] = []; } const parentArray = parent[previousItemPath]; if (!parentArray[itemPathIndex]) { parentArray[itemPathIndex] = {}; } parent = parentArray; previousItemPath = itemPathIndex; current = parent[itemPathIndex]; } else { if (!current[itemPathPart]) { current[itemPathPart] = {}; } parent = current; previousItemPath = itemPathPart; current = parent[itemPathPart]; } } parent[previousItemPath] = value; return yamlRoot; } /** * Set items for the chart * @param itemPath - item path in the YAML, if empty then root of the YAML object will be used * @param items - the element object * @param yamlRoot - root of the YAML object to update */ _setChartItems(itemPath, items, yamlRoot) { if (!items) { return; } const dotItems = dot.dot(items); for (const key in dotItems) { let itemKey = key; // if it is an array key like extraEnvironment[0].JAVA_OPTS, convert it into a dot separated key as extraEnvironment.0.JAVA_OPTS if (key.includes('[')) { itemKey = key.replace('[', '.').replace(']', ''); } if (itemPath) { this._setValue(`${itemPath}.${itemKey}`, dotItems[key], yamlRoot); } else { this._setValue(itemKey, dotItems[key], yamlRoot); } } } async prepareStagingDirectory(consensusNodes, nodeAliases, yamlRoot, deploymentName, applicationPropertiesPath, stagingOptions) { const accountMap = this.accountManager.getNodeAccountMap(consensusNodes.map((node) => node.name), deploymentName); // set consensus pod level resources for (const [nodeIndex, nodeAlias] of nodeAliases.entries()) { this._setValue(`hedera.nodes.${nodeIndex}.name`, nodeAlias, yamlRoot); this._setValue(`hedera.nodes.${nodeIndex}.nodeId`, `${Templates.nodeIdFromNodeAlias(nodeAlias)}`, yamlRoot); this._setValue(`hedera.nodes.${nodeIndex}.accountId`, accountMap.get(nodeAlias), yamlRoot); } // Resolve once and keep immutable for this invocation to prevent races from global flag mutation // while a parallel command is generating staging/config artifacts. const resolvedStagingOptions = this.resolveStagingOptions(stagingOptions); const stagingDirectory = Templates.renderStagingDir(resolvedStagingOptions.cacheDir, resolvedStagingOptions.releaseTag); if (!fs.existsSync(stagingDirectory)) { fs.mkdirSync(stagingDirectory, { recursive: true }); } const needsConfigTxt = versions.needsConfigTxtForConsensusVersion(resolvedStagingOptions.releaseTag); let configTxtPath; if (needsConfigTxt) { const gossipFqdnRestricted = await this.getGossipFqdnRestricted(consensusNodes, applicationPropertiesPath); configTxtPath = await this.prepareConfigTxt(accountMap, consensusNodes, stagingDirectory, resolvedStagingOptions.releaseTag, resolvedStagingOptions.appName, resolvedStagingOptions.chainId, gossipFqdnRestricted); } // Update application.properties with shard and realm await this.updateApplicationPropertiesWithRealmAndShard(applicationPropertiesPath, this.localConfig.configuration.realmForDeployment(deploymentName), this.localConfig.configuration.shardForDeployment(deploymentName)); await this.updateApplicationPropertiesForBlockNode(applicationPropertiesPath); await this.updateApplicationPropertiesWithChainId(applicationPropertiesPath, resolvedStagingOptions.chainId); for (const flag of flags.nodeConfigFileFlags.values()) { const sourceFilePath = this.configManager.getFlagFile(flag); const currentWorkingDirectory = process.env.INIT_CWD || process.cwd(); const sourceAbsoluteFilePath = PathEx.resolve(currentWorkingDirectory, sourceFilePath); if (!fs.existsSync(sourceAbsoluteFilePath)) { throw new SoloError(`Configuration file does not exist for: ${flag.name}, absolute path: ${sourceAbsoluteFilePath}, path: ${sourceFilePath}`); } const destinationFileName = path.basename(flag.definition.defaultValue); const destinationPath = PathEx.join(stagingDirectory, 'templates', destinationFileName); this.logger.debug(`Copying configuration file to staging: ${sourceAbsoluteFilePath} -> ${destinationPath}`); // For application.properties: when the user provides a custom file (flag value differs // from the default relative path), use the user's file as the base and then apply // Solo's required overrides (realm, shard, block-node settings) on top. // This preserves all user-defined properties while ensuring Solo's critical settings win. const flagValue = this.configManager.getFlag(flags.applicationProperties); const isUserSuppliedApplicationProperties = flag.name === flags.applicationProperties.name && !!flagValue && flagValue !== flags.applicationProperties.definition.defaultValue; if (isUserSuppliedApplicationProperties) { // Base: Solo's updated default (realm/shard/block-node settings already applied). // Apply user's properties as key-level overrides: existing keys are updated, // new keys are appended. This avoids duplicates while preserving all Solo defaults // that the user did not explicitly override. fs.cpSync(applicationPropertiesPath, destinationPath, { force: true }); await this.mergeApplicationProperties(destinationPath, sourceAbsoluteFilePath); } else { fs.cpSync(sourceAbsoluteFilePath, destinationPath, { force: true }); } } const bootstrapPropertiesPath = PathEx.join(stagingDirectory, 'templates', 'bootstrap.properties'); await this.updateBoostrapPropertiesWithChainId(bootstrapPropertiesPath, resolvedStagingOptions.chainId); if (configTxtPath) { this._setFileContentsAsValue('hedera.configMaps.configTxt', configTxtPath, yamlRoot); } this._setFileContentsAsValue('hedera.configMaps.log4j2Xml', PathEx.joinWithRealPath(stagingDirectory, 'templates', 'log4j2.xml'), yamlRoot); this._setFileContentsAsValue('hedera.configMaps.settingsTxt', PathEx.joinWithRealPath(stagingDirectory, 'templates', 'settings.txt'), yamlRoot); this._setFileContentsAsValue('hedera.configMaps.applicationProperties', PathEx.joinWithRealPath(stagingDirectory, 'templates', constants.APPLICATION_PROPERTIES), yamlRoot); this._setFileContentsAsValue('hedera.configMaps.apiPermissionsProperties', PathEx.joinWithRealPath(stagingDirectory, 'templates', 'api-permission.properties'), yamlRoot); this._setFileContentsAsValue('hedera.configMaps.bootstrapProperties', PathEx.joinWithRealPath(stagingDirectory, 'templates', 'bootstrap.properties'), yamlRoot); const applicationEnvironmentPath = PathEx.join(stagingDirectory, 'templates', 'application.env'); this._setFileContentsAsValue('hedera.configMaps.applicationEnv', PathEx.resolve(applicationEnvironmentPath), yamlRoot); try { if (this.remoteConfig.configuration.state.blockNodes.length === 0 && this.remoteConfig.configuration.state.externalBlockNodes.length === 0) { return; } } catch { // quick fix for tests where field on remote config are unaccessible return; } for (const node of consensusNodes) { const blockNodesJsonData = new BlockNodesJsonWrapper(node.blockNodeMap, node.externalBlockNodeMap).toJSON(); let nodeIndex = 0; for (const [index, nodeAlias] of nodeAliases.entries()) { if (nodeAlias === node.name) { nodeIndex = index; } } // Create a unique filename for each consensus node const blockNodesJsonFilename = `${constants.BLOCK_NODES_JSON_FILE.replace('.json', '')}-${node.name}.json`; const blockNodesJsonPath = PathEx.join(constants.SOLO_CACHE_DIR, blockNodesJsonFilename); fs.writeFileSync(blockNodesJsonPath, JSON.stringify(JSON.parse(blockNodesJsonData), undefined, 2)); this._setFileContentsAsValue(`hedera.nodes.${nodeIndex}.blockNodesJson`, blockNodesJsonPath, yamlRoot); } } /** * Parse a KEY=VALUE env file and override defaults.root.extraEnvironment in the Helm values * so that pod-level environment variables match the application.env content. */ applyApplicationEnvToExtraEnv(applicationEnvironmentPath, yamlRoot) { if (!fs.existsSync(applicationEnvironmentPath)) { return; } const extraEnvironment = []; for (const line of fs.readFileSync(applicationEnvironmentPath, 'utf8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; } const equalsIndex = trimmed.indexOf('='); if (equalsIndex > 0) { extraEnvironment.push({ name: trimmed.slice(0, equalsIndex), value: trimmed.slice(equalsIndex + 1) }); } } if (extraEnvironment.length > 0) { this._setChartItems('defaults.root', { extraEnv: extraEnvironment }, yamlRoot); } } resourcesForNetworkUpgrade(itemPath, fileName, stagingDirectory, yamlRoot) { const filePath = PathEx.join(stagingDirectory, 'templates', fileName); if (!fs.existsSync(filePath)) { return; } this._setFileContentsAsValue(itemPath, filePath, yamlRoot); } /** * Prepare a values file for Solo Helm chart * @param consensusNodes - the list of consensus nodes * @param deploymentName * @param applicationPropertiesPath * @param jfrFile - the name of the custom JFR settings file to use for recording (basename only) * @param stagingOptions * @returns mapping of cluster-ref to the full path to the values file */ async prepareValuesForSoloChart(consensusNodes, deploymentName, applicationPropertiesPath, jfrFile = '', stagingOptions) { const filesMapping = {}; for (const [clusterReference] of this.remoteConfig.getClusterRefs()) { const nodeAliases = consensusNodes .filter((node) => node.cluster === clusterReference) .map((node) => node.name); // generate the YAML const yamlRoot = {}; await this.prepareStagingDirectory(consensusNodes, nodeAliases, yamlRoot, deploymentName, applicationPropertiesPath, stagingOptions); // If a JFR settings file is provided, read the defaults from solo-values.yaml, // find the JAVA_OPTS entry in defaults.root.extraEnv, and append the // -XX:StartFlightRecording flags so that the recorder starts automatically // when the consensus node JVM launches. if (jfrFile !== '') { const soloValuesYaml = yaml.parse(fs.readFileSync(constants.SOLO_DEPLOYMENT_VALUES_FILE, 'utf8')); const extraEnvironment = soloValuesYaml?.defaults?.root?.extraEnv ?? []; const javaOption = extraEnvironment.find((environmentObject) => environmentObject.name === 'JAVA_OPTS'); if (javaOption) { javaOption.value += ' -XX:StartFlightRecording=dumponexit=true' + `,settings=${constants.HEDERA_HAPI_PATH}/data/config/${jfrFile}` + `,filename=${constants.HEDERA_HAPI_PATH}/output/recording.jfr`; } else { this.logger.warn(`JAVA_OPTS not found in ${constants.SOLO_DEPLOYMENT_VALUES_FILE}; JFR settings file '${jfrFile}' will not be applied`); } this._setChartItems('defaults.root', soloValuesYaml.defaults.root, yamlRoot); } // Override defaults.root.extraEnv with values from the staged application.env file. // This must run AFTER the JFR block above, which overwrites defaults.root from solo-values.yaml. const stagingDirectory = Templates.renderStagingDir(this.configManager.getFlag(flags.cacheDir), this.configManager.getFlag(flags.releaseTag)); const applicationEnvironmentPath = PathEx.join(stagingDirectory, 'templates', 'application.env'); this.applyApplicationEnvToExtraEnv(applicationEnvironmentPath, yamlRoot); const cachedValuesFile = PathEx.join(this.cacheDir, `solo-${clusterReference}.yaml`); filesMapping[clusterReference] = await this.writeToYaml(cachedValuesFile, yamlRoot); } return filesMapping; } resolveStagingOptions(options) { // Fallbacks preserve compatibility for call sites that do not pass explicit options yet. // Newer call sites should pass command-scoped values to avoid cross-command interference. return { cacheDir: options?.cacheDir ?? this.configManager.getFlag(flags.cacheDir), releaseTag: options?.releaseTag ?? this.configManager.getFlag(flags.releaseTag), appName: options?.appName ?? this.configManager.getFlag(flags.app), chainId: options?.chainId ?? this.configManager.getFlag(flags.chainId), }; } async bumpHederaConfigVersion(applicationPropertiesPath) { const fileContents = await readFile(applicationPropertiesPath, 'utf8'); const lines = fileContents.split('\n'); for (const line of lines) { if (line.startsWith('hedera.config.version=')) { const version = Number.parseInt(line.split('=')[1], 10) + 1; lines[lines.indexOf(line)] = `hedera.config.version=${version}`; break; } } await writeFile(applicationPropertiesPath, lines.join('\n')); } /** * Merge a user-supplied application.properties into the existing staging file. * Solo's defaults (already written to stagingPath) are the base; for each key in the * user's file the existing line is replaced in-place. Keys not present in the base * are appended at the end. This avoids duplicate entries while preserving every * Solo default the user did not explicitly override. */ async mergeApplicationProperties(stagingPath, userFilePath) { this.logger.debug(`Merging user application.properties '${userFilePath}' into staging '${stagingPath}'`); const stagingContent = await readFile(stagingPath, 'utf8'); const userContent = await readFile(userFilePath, 'utf8'); // Parse user file into key→value map (comments and blank lines are skipped) const userProperties = new Map(); for (const line of userContent.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; } const equalsIndex = trimmed.indexOf('='); if (equalsIndex > 0) { userProperties.set(trimmed.slice(0, equalsIndex).trim(), trimmed.slice(equalsIndex + 1)); } } // Walk staging lines, replacing values for keys the user supplied const appliedKeys = new Set(); const resultLines = []; for (const line of stagingContent.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { resultLines.push(line); continue; } const equalsIndex = trimmed.indexOf('='); if (equalsIndex > 0) { const key = trimmed.slice(0, equalsIndex).trim(); if (userProperties.has(key)) { resultLines.push(`${key}=${userProperties.get(key)}`); appliedKeys.add(key); } else { resultLines.push(line); } } else { resultLines.push(line); } } // Append keys from user's file that were not present in Solo's default for (const [key, value] of userProperties) { if (!appliedKeys.has(key)) { resultLines.push(`${key}=${value}`); } } await writeFile(stagingPath, resultLines.join('\n')); } async updateApplicationPropertiesForBlockNode(applicationPropertiesPath) { const blockNodes = this.remoteConfig.configuration.components.state.blockNodes; const hasDeployedBlockNodes = blockNodes.length > 0; if (!hasDeployedBlockNodes) { return; } const lines = await readFile(applicationPropertiesPath, 'utf8').then((fileText) => fileText.split('\n')); const streamMode = constants.BLOCK_STREAM_STREAM_MODE; const writerMode = constants.BLOCK_STREAM_WRITER_MODE; let streamModeUpdated = false; let writerModeUpdated = false; for (const line of lines) { if (line.startsWith('blockStream.streamMode=')) { lines[lines.indexOf(line)] = `blockStream.streamMode=${streamMode}`; streamModeUpdated = true; continue; } if (line.startsWith('blockStream.writerMode=')) { lines[lines.indexOf(line)] = `blockStream.writerMode=${writerMode}`; writerModeUpdated = true; } } if (!streamModeUpdated) { lines.push(`blockStream.streamMode=${streamMode}`); } if (!writerModeUpdated) { lines.push(`blockStream.writerMode=${writerMode}`); } await writeFile(applicationPropertiesPath, lines.join('\n') + '\n'); } async updateApplicationPropertiesWithChainId(applicationPropertiesPath, chainId) { const fileText = await readFile(applicationPropertiesPath, 'utf8'); const lines = fileText.split('\n'); for (const line of lines) { if (line.startsWith('contracts.chainId=')) { lines[lines.indexOf(line)] = `contracts.chainId=${chainId}`; } } await writeFile(applicationPropertiesPath, lines.join('\n') + '\n'); } async updateBoostrapPropertiesWithChainId(bootstrapPropertiesPath, chainId) { const fileText = await readFile(bootstrapPropertiesPath, 'utf8'); const lines = fileText.split('\n'); for (const line of lines) { if (line.startsWith('contracts.chainId=')) { lines[lines.indexOf(line)] = `contracts.chainId=${chainId}`; } } await writeFile(bootstrapPropertiesPath, lines.join('\n') + '\n'); } async updateApplicationPropertiesWithRealmAndShard(applicationPropertiesPath, realm, shard) { const fileContents = await readFile(applicationPropertiesPath, 'utf8'); const lines = fileContents.split('\n'); let realmUpdated = false; let shardUpdated = false; for (const line of lines) { if (line.startsWith('hedera.realm=')) { lines[lines.indexOf(line)] = `hedera.realm=${realm}`; realmUpdated = true; continue; } if (line.startsWith('hedera.shard=')) { lines[lines.indexOf(line)] = `hedera.shard=${shard}`; shardUpdated = true; } } if (!realmUpdated) { lines.push(`hedera.realm=${realm}`); } if (!shardUpdated) { lines.push(`hedera.shard=${shard}`); } let releaseTag = new SemanticVersion(versions.HEDERA_PLATFORM_VERSION); try { releaseTag = this.remoteConfig.configuration.versions.consensusNode; } catch { // Guard } let tssEnabled = false; try { tssEnabled = this.remoteConfig.configuration.state.tssEnabled; } catch { // Guard } if (!releaseTag.lessThan(versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_TSS) && tssEnabled) { lines.push('tss.hintsEnabled=true', 'tss.historyEnabled=true', 'tss.forceMockSignatures=false'); if (this.remoteConfig.configuration.state.wrapsEnabled) { lines.push('tss.wrapsEnabled=true'); } } await writeFile(applicationPropertiesPath, lines.join('\n') + '\n'); } async prepareValuesForNodeTransaction(applicationPropertiesPath, configTxtPath) { const yamlRoot = {}; if (configTxtPath) { this._setFileContentsAsValue('hedera.configMaps.configTxt', configTxtPath, yamlRoot); } await this.bumpHederaConfigVersion(applicationPropertiesPath); this._setFileContentsAsValue('hedera.configMaps.applicationProperties', applicationPropertiesPath, yamlRoot); const cachedValuesFile = PathEx.join(this.cacheDir, 'solo-node-transaction.yaml'); return this.writeToYaml(cachedValuesFile, yamlRoot); } /** * Writes the YAML to file. * * @param cachedValuesFile - the target file to write the YAML root to. * @param yamlRoot - object to turn into YAML and write to file. */ async writeToYaml(cachedValuesFile, yamlRoot) { return await new Promise((resolve, reject) => { fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), (error) => { if (error) { reject(error); } resolve(cachedValuesFile); }); }); } /** * Writes the contents of a file as a value for the given nested item path in the YAML object * @param itemPath - nested item path in the YAML object to store the file contents * @param valueFilePath - path to the file whose contents will be stored in the YAML object * @param yamlRoot - root of the YAML object */ _setFileContentsAsValue(itemPath, valueFilePath, yamlRoot) { const fileContents = fs.readFileSync(valueFilePath, 'utf8'); this._setValue(itemPath, fileContents, yamlRoot); } /** * Extracts gossip endpoints from saved state (network.json) if it exists * @param consensusNode - the consensus node to check * @param nodeSeq - the node sequence number (index in roster) * @returns the saved endpoint address or undefined if no saved state exists or IP is no longer valid * @private */ async extractSavedEndpoint(consensusNode, nodeSeq) { try { const k8 = this.k8Factory.getK8(consensusNode.context); const networkJsonPath = `${constants.HEDERA_HAPI_PATH}/output/network.json`; // Check if network.json exists in the pod const pods = await k8 .pods() .list(NamespaceName.of(consensusNode.namespace), [`app=network-${consensusNode.name}`]); if (pods.length === 0) { return undefined; } const pod = pods[0]; const podReference = pod.podReference; // Get container reference const containerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER); const container = k8.containers().readByRef(containerReference); // Try to read network.json from the pod const networkJsonContent = await container.execContainer(['cat', networkJsonPath]); if (!networkJsonContent || networkJsonContent.includes('No such file')) { return undefined; } const networkJson = JSON.parse(networkJsonContent); const nodeMetadata = networkJson?.nodeMetadata?.[nodeSeq]; const rosterEntry = nodeMetadata?.rosterEntry; const gossipEndpointRaw = rosterEntry?.gossipEndpoint?.[0]; const port = gossipEndpointRaw?.port || 0; const domainName = typeof gossipEndpointRaw?.domainName === 'string' ? gossipEndpointRaw.domainName : undefined; const ipAddressV4 = typeof gossipEndpointRaw?.ipAddressV4 === 'string' ? gossipEndpointRaw.ipAddressV4 : undefined; if (!gossipEndpointRaw) { return undefined; } // Check if endpoint uses domain name (FQDN) if (domainName) { this.logger.info(`Found saved endpoint for ${consensusNode.name}: ${domainName}:${port} (FQDN)`); return new Address(port, domainName); } // Check if endpoint uses IP address if (ipAddressV4) { // Decode base64 IP address const base64Ip = ipAddressV4; const ipBytes = Buffer.from(base64Ip, 'base64'); const ipAddress = [...ipBytes].join('.'); // Validate the saved IP still belongs to this node service. const serviceName = `network-${consensusNode.name}-svc`; const service = await k8 .services() .read(NamespaceName.of(consensusNode.namespace), serviceName); const serviceIpAddress = service?.spec?.clusterIP; if (serviceIpAddress !== ipAddress) { this.logger.warn(`Saved endpoint ${ipAddress}:${port} for ${consensusNode.name} does not match current ${serviceName} ClusterIP ${serviceIpAddress ?? 'undefined'}, falling back to current service address`); return undefined; } this.logger.info(`Found saved endpoint for ${consensusNode.name}: ${ipAddress}:${port} (IP)`); return new Address(port, ipAddress); } return undefined; } catch (error) { // If anything fails, return undefined to fall back to getExternalAddress this.logger.debug(`Could not extract saved endpoint for ${consensusNode.name}: ${error instanceof Error ? error.message : 'unknown error'}`); return undefined; } } /** * Prepares config.txt file for the node * @param nodeAccountMap - the map of node aliases to account IDs * @param consensusNodes - the list of consensus nodes * @param destinationPath * @param releaseTagOverride - release tag override * @param [appName] - the app name (default: HederaNode.jar) * @param [chainId] - chain ID (298 for local network) * @returns the config.txt file path */ async prepareConfigTxt(nodeAccountMap, consensusNodes, destinationPath, releaseTagOverride, appName = constants.HEDERA_APP_NAME, chainId = constants.HEDERA_CHAIN_ID, gossipFqdnRestricted = true) { let releaseTag = releaseTagOverride; if (!nodeAccountMap || nodeAccountMap.size === 0) { throw new MissingArgumentError('nodeAccountMap the map of node IDs to account IDs is required'); } if (!releaseTag) { releaseTag = versions.HEDERA_PLATFORM_VERSION; } if (!fs.existsSync(destinationPath)) { throw new IllegalArgumentError(`config destPath does not exist: ${destinationPath}`, destinationPath); } const configFilePath = PathEx.join(destinationPath, 'config.txt'); if (fs.existsSync(configFilePath)) { fs.unlinkSync(configFilePath); } // init variables const internalPort = +constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT; const externalPort = +constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT; const nodeStakeAmount = constants.HEDERA_NODE_DEFAULT_STAKE_AMOUNT; const releaseVersion = new SemanticVersion(releaseTag); try { const configLines = [`swirld, ${chainId}`, `app, ${appName}`]; let nodeSeq = 0; for (const consensusNode of consensusNodes) { const internalIP = helpers.getInternalAddress(releaseVersion, NamespaceName.of(consensusNode.namespace), consensusNode.name); // First try to extract endpoint from saved state (migration scenario) let address = await this.extractSavedEndpoint(consensusNode, nodeSeq); // If no saved state, get current external address if (!address) { address = await Address.getExternalAddress(consensusNode, this.k8Factory.getK8(consensusNode.context), externalPort, gossipFqdnRestricted); } const account = nodeAccountMap.get(consensusNode.name); configLines.push(`address, ${nodeSeq}, ${nodeSeq}, ${consensusNode.name}, ${nodeStakeAmount}, ${internalIP}, ${internalPort}, ${address.hostString()}, ${address.port}, ${account}`); nodeSeq += 1; } // TODO: remove once we no longer need less than v0.56 if (releaseVersion.minor >= 41 && releaseVersion.minor < 56) { configLines.push(`nextNodeId, ${nodeSeq}`); } fs.writeFileSync(configFilePath, configLines.join('\n')); return configFilePath; } catch (error) { throw new SoloError(`failed to generate config.txt, ${error instanceof Error ? error.message : 'unknown error'}`, error); } } parseGossipFqdnRestricted(applicationPropertiesText) { const match = applicationPropertiesText.match(/^\s*nodes\.gossipFqdnRestricted\s*=\s*(true|false)\s*$/m); if (match?.[1]) { return match[1].toLowerCase() === 'true'; } return undefined; } async getGossipFqdnRestricted(consensusNodes, applicationPropertiesPath) { const firstNode = consensusNodes[0]; if (firstNode) { try { const k8 = this.k8Factory.getK8(firstNode.context); const configMap = await k8 .configMaps() .read(NamespaceName.of(firstNode.namespace), constants.NETWORK_NODE_SHARED_DATA_CONFIG_MAP_NAME); const configMapProperties = configMap.data?.[constants.APPLICATION_PROPERTIES]; if (configMapProperties) { const parsedFromConfigMap = this.parseGossipFqdnRestricted(configMapProperties); if (parsedFromConfigMap !== undefined) { return parsedFromConfigMap; } } } catch { // Fall through to local application.properties } } if (fs.existsSync(applicationPropertiesPath)) { const applicationPropertiesContent = fs.readFileSync(applicationPropertiesPath, 'utf8'); const parsedFromApplicationProperties = this.parseGossipFqdnRestricted(applicationPropertiesContent); if (parsedFromApplicationProperties !== undefined) { return parsedFromApplicationProperties; } } return true; } }; ProfileManager = __decorate([ injectable(), __param(0, inject(InjectTokens.SoloLogger)), __param(1, inject(InjectTokens.ConfigManager)), __param(2, inject(InjectTokens.CacheDir)), __param(3, inject(InjectTokens.K8Factory)), __param(4, inject(InjectTokens.RemoteConfigRuntimeState)), __param(5, inject(InjectTokens.AccountManager)), __param(6, inject(InjectTokens.LocalConfigRuntimeState)), __metadata("design:paramtypes", [Object, Function, String, Object, Object, AccountManager, LocalConfigRuntimeState]) ], ProfileManager); export { ProfileManager }; //# sourceMappingURL=profile-manager.js.map