@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
672 lines • 36.4 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
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