@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
916 lines (800 loc) • 37.7 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
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 {type ConfigManager} from './config-manager.js';
import * as helpers from './helpers.js';
import {type SoloLogger} from './logging/solo-logger.js';
import {type AnyObject, type DirectoryPath, type NodeAlias, type NodeAliases, type Path} from '../types/aliases.js';
import {type Optional} from '../types/index.js';
import {inject, injectable} from 'tsyringe-neo';
import {patchInject} from './dependency-injection/container-helper.js';
import {InjectTokens} from './dependency-injection/inject-tokens.js';
import {type ConsensusNode} from './model/consensus-node.js';
import {type K8Factory} from '../integration/kube/k8-factory.js';
import {type K8} from '../integration/kube/k8.js';
import {ContainerReference} from '../integration/kube/resources/container/container-reference.js';
import {type Pod} from '../integration/kube/resources/pod/pod.js';
import {type PodReference} from '../integration/kube/resources/pod/pod-reference.js';
import {type Container} from '../integration/kube/resources/container/container.js';
import {type ConfigMap} from '../integration/kube/resources/config-map/config-map.js';
import {type ClusterReferenceName, DeploymentName, Realm, Shard} from './../types/index.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 {type RemoteConfigRuntimeStateApi} from '../business/runtime-state/api/remote-config-runtime-state-api.js';
import {BlockNodeStateSchema} from '../data/schema/model/remote/state/block-node-state-schema.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';
export interface ProfileManagerStagingOptions {
// These values are intentionally passed from the command's resolved config so profile generation
// does not depend on mutable global flags that can be changed by concurrently running subcommands.
cacheDir: DirectoryPath;
releaseTag: string;
appName: string;
chainId: string;
}
()
export class ProfileManager {
private readonly logger: SoloLogger;
private readonly configManager: ConfigManager;
private readonly cacheDir: DirectoryPath;
private readonly k8Factory: K8Factory;
private readonly remoteConfig: RemoteConfigRuntimeStateApi;
private readonly accountManager: AccountManager;
private readonly localConfig: LocalConfigRuntimeState;
public constructor(
(InjectTokens.SoloLogger) logger?: SoloLogger,
(InjectTokens.ConfigManager) configManager?: ConfigManager,
(InjectTokens.CacheDir) cacheDirectory?: DirectoryPath,
(InjectTokens.K8Factory) k8Factory?: K8Factory,
(InjectTokens.RemoteConfigRuntimeState) remoteConfig?: RemoteConfigRuntimeStateApi,
(InjectTokens.AccountManager) accountManager?: AccountManager,
(InjectTokens.LocalConfigRuntimeState) localConfig?: LocalConfigRuntimeState,
) {
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
*/
public _setValue(itemPath: string, value: unknown, yamlRoot: AnyObject): AnyObject {
// find the location where to set the value in the YAML
const itemPathParts: string[] = itemPath.split('.');
let parent: AnyObject = yamlRoot;
let current: AnyObject = parent;
let previousItemPath: string | number = '';
for (const itemPathPart of itemPathParts) {
if (Numbers.isNumeric(itemPathPart)) {
const itemPathIndex: number = Number.parseInt(itemPathPart, 10); // numeric path part can only be array index
if (!Array.isArray(parent[previousItemPath])) {
parent[previousItemPath] = [];
}
const parentArray: AnyObject[] = parent[previousItemPath] as AnyObject[];
if (!parentArray[itemPathIndex]) {
parentArray[itemPathIndex] = {};
}
parent = parentArray as unknown as AnyObject;
previousItemPath = itemPathIndex;
current = parent[itemPathIndex] as AnyObject;
} 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
*/
public _setChartItems(itemPath: string, items: AnyObject | undefined, yamlRoot: AnyObject): void {
if (!items) {
return;
}
const dotItems: AnyObject = dot.dot(items) as AnyObject;
for (const key in dotItems) {
let itemKey: string = 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);
}
}
}
public async prepareStagingDirectory(
consensusNodes: ConsensusNode[],
nodeAliases: NodeAliases,
yamlRoot: AnyObject,
deploymentName: DeploymentName,
applicationPropertiesPath: string,
stagingOptions?: Partial<ProfileManagerStagingOptions>,
): Promise<void> {
const accountMap: Map<NodeAlias, string> = this.accountManager.getNodeAccountMap(
consensusNodes.map((node): NodeAlias => 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: ProfileManagerStagingOptions = this.resolveStagingOptions(stagingOptions);
const stagingDirectory: string = Templates.renderStagingDir(
resolvedStagingOptions.cacheDir,
resolvedStagingOptions.releaseTag,
);
if (!fs.existsSync(stagingDirectory)) {
fs.mkdirSync(stagingDirectory, {recursive: true});
}
const needsConfigTxt: boolean = versions.needsConfigTxtForConsensusVersion(resolvedStagingOptions.releaseTag);
let configTxtPath: Optional<string>;
if (needsConfigTxt) {
const gossipFqdnRestricted: boolean = 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: string = this.configManager.getFlagFile(flag);
const currentWorkingDirectory: string = process.env.INIT_CWD || process.cwd();
const sourceAbsoluteFilePath: string = 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: string = path.basename(flag.definition.defaultValue as string);
const destinationPath: string = 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: string | undefined = this.configManager.getFlag<string>(flags.applicationProperties);
const isUserSuppliedApplicationProperties: boolean =
flag.name === flags.applicationProperties.name &&
!!flagValue &&
flagValue !== (flags.applicationProperties.definition.defaultValue as string);
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: string = 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: string = 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: string = new BlockNodesJsonWrapper(
node.blockNodeMap,
node.externalBlockNodeMap,
).toJSON();
let nodeIndex: number = 0;
for (const [index, nodeAlias] of nodeAliases.entries()) {
if (nodeAlias === node.name) {
nodeIndex = index;
}
}
// Create a unique filename for each consensus node
const blockNodesJsonFilename: string = `${constants.BLOCK_NODES_JSON_FILE.replace('.json', '')}-${node.name}.json`;
const blockNodesJsonPath: string = 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.
*/
private applyApplicationEnvToExtraEnv(applicationEnvironmentPath: string, yamlRoot: AnyObject): void {
if (!fs.existsSync(applicationEnvironmentPath)) {
return;
}
const extraEnvironment: AnyObject[] = [];
for (const line of fs.readFileSync(applicationEnvironmentPath, 'utf8').split('\n')) {
const trimmed: string = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const equalsIndex: number = 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);
}
}
public resourcesForNetworkUpgrade(
itemPath: string,
fileName: string,
stagingDirectory: string,
yamlRoot: AnyObject,
): void {
const filePath: string = 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
*/
public async prepareValuesForSoloChart(
consensusNodes: ConsensusNode[],
deploymentName: DeploymentName,
applicationPropertiesPath: string,
jfrFile: string = '',
stagingOptions?: Partial<ProfileManagerStagingOptions>,
): Promise<Record<ClusterReferenceName, string>> {
const filesMapping: Record<ClusterReferenceName, string> = {};
for (const [clusterReference] of this.remoteConfig.getClusterRefs()) {
const nodeAliases: NodeAliases = consensusNodes
.filter((node): boolean => node.cluster === clusterReference)
.map((node): NodeAlias => node.name);
// generate the YAML
const yamlRoot: AnyObject = {};
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: AnyObject = yaml.parse(
fs.readFileSync(constants.SOLO_DEPLOYMENT_VALUES_FILE, 'utf8'),
) as AnyObject;
const extraEnvironment: AnyObject[] = (soloValuesYaml?.defaults?.root?.extraEnv as AnyObject[]) ?? [];
const javaOption: AnyObject | undefined = extraEnvironment.find(
(environmentObject: AnyObject): boolean => 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: string = Templates.renderStagingDir(
this.configManager.getFlag(flags.cacheDir),
this.configManager.getFlag(flags.releaseTag),
);
const applicationEnvironmentPath: string = PathEx.join(stagingDirectory, 'templates', 'application.env');
this.applyApplicationEnvToExtraEnv(applicationEnvironmentPath, yamlRoot);
const cachedValuesFile: string = PathEx.join(this.cacheDir, `solo-${clusterReference}.yaml`);
filesMapping[clusterReference] = await this.writeToYaml(cachedValuesFile, yamlRoot);
}
return filesMapping;
}
private resolveStagingOptions(options?: Partial<ProfileManagerStagingOptions>): ProfileManagerStagingOptions {
// 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),
};
}
private async bumpHederaConfigVersion(applicationPropertiesPath: string): Promise<void> {
const fileContents: string = await readFile(applicationPropertiesPath, 'utf8');
const lines: string[] = fileContents.split('\n');
for (const line of lines) {
if (line.startsWith('hedera.config.version=')) {
const version: number = 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.
*/
private async mergeApplicationProperties(stagingPath: string, userFilePath: string): Promise<void> {
this.logger.debug(`Merging user application.properties '${userFilePath}' into staging '${stagingPath}'`);
const stagingContent: string = await readFile(stagingPath, 'utf8');
const userContent: string = await readFile(userFilePath, 'utf8');
// Parse user file into key→value map (comments and blank lines are skipped)
const userProperties: Map<string, string> = new Map<string, string>();
for (const line of userContent.split('\n')) {
const trimmed: string = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const equalsIndex: number = 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: Set<string> = new Set<string>();
const resultLines: string[] = [];
for (const line of stagingContent.split('\n')) {
const trimmed: string = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
resultLines.push(line);
continue;
}
const equalsIndex: number = trimmed.indexOf('=');
if (equalsIndex > 0) {
const key: string = 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'));
}
private async updateApplicationPropertiesForBlockNode(applicationPropertiesPath: string): Promise<void> {
const blockNodes: BlockNodeStateSchema[] = this.remoteConfig.configuration.components.state.blockNodes;
const hasDeployedBlockNodes: boolean = blockNodes.length > 0;
if (!hasDeployedBlockNodes) {
return;
}
const lines: string[] = await readFile(applicationPropertiesPath, 'utf8').then((fileText): string[] =>
fileText.split('\n'),
);
const streamMode: string = constants.BLOCK_STREAM_STREAM_MODE;
const writerMode: string = constants.BLOCK_STREAM_WRITER_MODE;
let streamModeUpdated: boolean = false;
let writerModeUpdated: boolean = 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');
}
private async updateApplicationPropertiesWithChainId(
applicationPropertiesPath: string,
chainId: string,
): Promise<void> {
const fileText: string = await readFile(applicationPropertiesPath, 'utf8');
const lines: string[] = 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');
}
private async updateBoostrapPropertiesWithChainId(bootstrapPropertiesPath: string, chainId: string): Promise<void> {
const fileText: string = await readFile(bootstrapPropertiesPath, 'utf8');
const lines: string[] = 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');
}
private async updateApplicationPropertiesWithRealmAndShard(
applicationPropertiesPath: string,
realm: Realm,
shard: Shard,
): Promise<void> {
const fileContents: string = await readFile(applicationPropertiesPath, 'utf8');
const lines: string[] = fileContents.split('\n');
let realmUpdated: boolean = false;
let shardUpdated: boolean = 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: SemanticVersion<string> = new SemanticVersion<string>(versions.HEDERA_PLATFORM_VERSION);
try {
releaseTag = this.remoteConfig.configuration.versions.consensusNode;
} catch {
// Guard
}
let tssEnabled: boolean = 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');
}
public async prepareValuesForNodeTransaction(
applicationPropertiesPath: string,
configTxtPath?: string,
): Promise<string> {
const yamlRoot: AnyObject = {};
if (configTxtPath) {
this._setFileContentsAsValue('hedera.configMaps.configTxt', configTxtPath, yamlRoot);
}
await this.bumpHederaConfigVersion(applicationPropertiesPath);
this._setFileContentsAsValue('hedera.configMaps.applicationProperties', applicationPropertiesPath, yamlRoot);
const cachedValuesFile: string = 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.
*/
public async writeToYaml(cachedValuesFile: Path, yamlRoot: AnyObject): Promise<string> {
return await new Promise<string>((resolve, reject): void => {
fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), (error): void => {
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
*/
private _setFileContentsAsValue(itemPath: string, valueFilePath: string, yamlRoot: AnyObject): void {
const fileContents: string = 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
*/
private async extractSavedEndpoint(consensusNode: ConsensusNode, nodeSeq: number): Promise<Address | undefined> {
try {
const k8: K8 = this.k8Factory.getK8(consensusNode.context);
const networkJsonPath: string = `${constants.HEDERA_HAPI_PATH}/output/network.json`;
// Check if network.json exists in the pod
const pods: Pod[] = await k8
.pods()
.list(NamespaceName.of(consensusNode.namespace), [`app=network-${consensusNode.name}`]);
if (pods.length === 0) {
return undefined;
}
const pod: Pod = pods[0];
const podReference: PodReference = pod.podReference;
// Get container reference
const containerReference: ContainerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER);
const container: Container = k8.containers().readByRef(containerReference);
// Try to read network.json from the pod
const networkJsonContent: string = await container.execContainer(['cat', networkJsonPath]);
if (!networkJsonContent || networkJsonContent.includes('No such file')) {
return undefined;
}
const networkJson: Record<string, unknown> = JSON.parse(networkJsonContent);
const nodeMetadata: unknown = networkJson?.nodeMetadata?.[nodeSeq];
const rosterEntry: {gossipEndpoint?: Array<Record<string, unknown>>} | undefined = (
nodeMetadata as {rosterEntry?: {gossipEndpoint?: Array<Record<string, unknown>>}} | undefined
)?.rosterEntry;
const gossipEndpointRaw: Record<string, unknown> | undefined = rosterEntry?.gossipEndpoint?.[0];
const port: number = (gossipEndpointRaw?.port as number) || 0;
const domainName: string | undefined =
typeof gossipEndpointRaw?.domainName === 'string' ? gossipEndpointRaw.domainName : undefined;
const ipAddressV4: string | undefined =
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: string = ipAddressV4 as string;
const ipBytes: Buffer = Buffer.from(base64Ip, 'base64');
const ipAddress: string = [...ipBytes].join('.');
// Validate the saved IP still belongs to this node service.
const serviceName: string = `network-${consensusNode.name}-svc`;
const service: {spec?: {clusterIP?: string}} | undefined = await k8
.services()
.read(NamespaceName.of(consensusNode.namespace), serviceName);
const serviceIpAddress: string | undefined = 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: Error | unknown) {
// 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
*/
public async prepareConfigTxt(
nodeAccountMap: Map<NodeAlias, string>,
consensusNodes: ConsensusNode[],
destinationPath: string,
releaseTagOverride: string,
appName: string = constants.HEDERA_APP_NAME,
chainId: string = constants.HEDERA_CHAIN_ID,
gossipFqdnRestricted: boolean = true,
): Promise<string> {
let releaseTag: string = 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: string = PathEx.join(destinationPath, 'config.txt');
if (fs.existsSync(configFilePath)) {
fs.unlinkSync(configFilePath);
}
// init variables
const internalPort: number = +constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT;
const externalPort: number = +constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT;
const nodeStakeAmount: number = constants.HEDERA_NODE_DEFAULT_STAKE_AMOUNT;
const releaseVersion: SemanticVersion<string> = new SemanticVersion(releaseTag);
try {
const configLines: string[] = [`swirld, ${chainId}`, `app, ${appName}`];
let nodeSeq: number = 0;
for (const consensusNode of consensusNodes) {
const internalIP: string = helpers.getInternalAddress(
releaseVersion,
NamespaceName.of(consensusNode.namespace),
consensusNode.name as NodeAlias,
);
// First try to extract endpoint from saved state (migration scenario)
let address: Address | undefined = 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: string | undefined = nodeAccountMap.get(consensusNode.name as NodeAlias);
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: Error | unknown) {
throw new SoloError(
`failed to generate config.txt, ${error instanceof Error ? (error as Error).message : 'unknown error'}`,
error,
);
}
}
private parseGossipFqdnRestricted(applicationPropertiesText: string): boolean | undefined {
const match: RegExpMatchArray | null = applicationPropertiesText.match(
/^\s*nodes\.gossipFqdnRestricted\s*=\s*(true|false)\s*$/m,
);
if (match?.[1]) {
return match[1].toLowerCase() === 'true';
}
return undefined;
}
private async getGossipFqdnRestricted(
consensusNodes: ConsensusNode[],
applicationPropertiesPath: string,
): Promise<boolean> {
const firstNode: ConsensusNode | undefined = consensusNodes[0];
if (firstNode) {
try {
const k8: K8 = this.k8Factory.getK8(firstNode.context);
const configMap: ConfigMap = await k8
.configMaps()
.read(NamespaceName.of(firstNode.namespace), constants.NETWORK_NODE_SHARED_DATA_CONFIG_MAP_NAME);
const configMapProperties: string | undefined = configMap.data?.[constants.APPLICATION_PROPERTIES];
if (configMapProperties) {
const parsedFromConfigMap: boolean | undefined = this.parseGossipFqdnRestricted(configMapProperties);
if (parsedFromConfigMap !== undefined) {
return parsedFromConfigMap;
}
}
} catch {
// Fall through to local application.properties
}
}
if (fs.existsSync(applicationPropertiesPath)) {
const applicationPropertiesContent: string = fs.readFileSync(applicationPropertiesPath, 'utf8');
const parsedFromApplicationProperties: boolean | undefined =
this.parseGossipFqdnRestricted(applicationPropertiesContent);
if (parsedFromApplicationProperties !== undefined) {
return parsedFromApplicationProperties;
}
}
return true;
}
}