diff --git a/.madgerc b/.madgerc new file mode 100644 index 000000000..b407c6b4b --- /dev/null +++ b/.madgerc @@ -0,0 +1,7 @@ +{ + "detectiveOptions": { + "ts": { + "skipTypeImports": true + } + } +} diff --git a/examples/performance-tuning/README.md b/examples/performance-tuning/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/commands/flags.ts b/src/commands/flags.ts index 3608799f0..485278a50 100644 --- a/src/commands/flags.ts +++ b/src/commands/flags.ts @@ -1645,6 +1645,7 @@ export class Flags { Flags.envoyIps, Flags.generateEcdsaKey, Flags.generateGossipKeys, + Flags.generateEcdsaKey, Flags.generateTlsKeys, Flags.gossipEndpoints, Flags.gossipPrivateKey, @@ -1665,6 +1666,7 @@ export class Flags { Flags.namespace, Flags.newAccountNumber, Flags.newAdminKey, + Flags.createAmount, Flags.nodeAlias, Flags.nodeAliasesUnparsed, Flags.operatorId, diff --git a/src/commands/network.ts b/src/commands/network.ts index a85009b12..ea6db9bdc 100644 --- a/src/commands/network.ts +++ b/src/commands/network.ts @@ -38,6 +38,7 @@ import {ConsensusNodeComponent} from '../core/config/remote/components/consensus import {ConsensusNodeStates} from '../core/config/remote/enumerations.js'; import {EnvoyProxyComponent} from '../core/config/remote/components/envoy_proxy_component.js'; import {HaProxyComponent} from '../core/config/remote/components/ha_proxy_component.js'; +import {GenesisNetworkDataConstructor} from '../core/genesis_network_models/genesis_network_data_constructor.js'; export interface NetworkDeployConfigClass { applicationEnv: string; @@ -61,6 +62,7 @@ export interface NetworkDeployConfigClass { grpcWebTlsCertificatePath: string; grpcTlsKeyPath: string; grpcWebTlsKeyPath: string; + genesisNetworkData: GenesisNetworkDataConstructor; getUnusedConfigs: () => string[]; haproxyIps: string; envoyIps: string; @@ -130,20 +132,19 @@ export class NetworkCommand extends BaseCommand { ]; } - async prepareValuesArg( - config: { - chartDirectory?: string; - app?: string; - nodeAliases?: string[]; - debugNodeAlias?: NodeAlias; - enablePrometheusSvcMonitor?: boolean; - releaseTag?: string; - persistentVolumeClaims?: string; - valuesFile?: string; - haproxyIpsParsed?: Record; - envoyIpsParsed?: Record; - } = {}, - ) { + async prepareValuesArg(config: { + chartDirectory?: string; + app?: string; + nodeAliases: string[]; + debugNodeAlias?: NodeAlias; + enablePrometheusSvcMonitor?: boolean; + releaseTag?: string; + persistentVolumeClaims?: string; + valuesFile?: string; + haproxyIpsParsed?: Record; + envoyIpsParsed?: Record; + genesisNetworkData: GenesisNetworkDataConstructor; + }) { let valuesArg = config.chartDirectory ? `-f ${path.join(config.chartDirectory, 'solo-deployment', 'values.yaml')}` : ''; @@ -160,7 +161,10 @@ export class NetworkCommand extends BaseCommand { } const profileName = this.configManager.getFlag(flags.profileName) as string; - this.profileValuesFile = await this.profileManager.prepareValuesForSoloChart(profileName); + this.profileValuesFile = await this.profileManager.prepareValuesForSoloChart( + profileName, + config.genesisNetworkData, + ); if (this.profileValuesFile) { valuesArg += this.prepareValuesFiles(this.profileValuesFile); } @@ -172,7 +176,7 @@ export class NetworkCommand extends BaseCommand { // Iterate over each node and set static IPs for HAProxy if (config.haproxyIpsParsed) { - config.nodeAliases?.forEach((nodeAlias, index) => { + config.nodeAliases.forEach((nodeAlias, index) => { const ip = config.haproxyIpsParsed?.[nodeAlias]; if (ip) valuesArg += ` --set "hedera.nodes[${index}].haproxyStaticIP=${ip}"`; @@ -181,7 +185,7 @@ export class NetworkCommand extends BaseCommand { // Iterate over each node and set static IPs for Envoy Proxy if (config.envoyIpsParsed) { - config.nodeAliases?.forEach((nodeAlias, index) => { + config.nodeAliases.forEach((nodeAlias, index) => { const ip = config.envoyIpsParsed?.[nodeAlias]; if (ip) valuesArg += ` --set "hedera.nodes[${index}].envoyProxyStaticIP=${ip}"`; @@ -253,13 +257,19 @@ export class NetworkCommand extends BaseCommand { constants.SOLO_DEPLOYMENT_CHART, ); - config.valuesArg = await this.prepareValuesArg(config); - // compute other config parameters config.keysDir = path.join(validatePath(config.cacheDir), 'keys'); config.stagingDir = Templates.renderStagingDir(config.cacheDir, config.releaseTag); config.stagingKeysDir = path.join(validatePath(config.stagingDir), 'keys'); + config.genesisNetworkData = await GenesisNetworkDataConstructor.initialize( + config.nodeAliases, + this.keyManager, + config.keysDir, + ); + + config.valuesArg = await this.prepareValuesArg(config); + if (!(await this.k8.hasNamespace(config.namespace))) { await this.k8.createNamespace(config.namespace); } @@ -341,7 +351,7 @@ export class NetworkCommand extends BaseCommand { }, { title: 'Check if cluster setup chart is installed', - task: async (ctx, task) => { + task: async () => { const isChartInstalled = await this.chartManager.isChartInstalled('', constants.SOLO_CLUSTER_SETUP_CHART); if (!isChartInstalled) { throw new SoloError( @@ -386,7 +396,7 @@ export class NetworkCommand extends BaseCommand { task: (ctx, parentTask) => { const config = ctx.config; - // set up the sub-tasks + // set up the subtasks return parentTask.newListr(self.platformInstaller.copyNodeKeys(config.stagingDir, config.nodeAliases), { concurrent: true, rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION, @@ -502,7 +512,7 @@ export class NetworkCommand extends BaseCommand { ), }); - // set up the sub-tasks + // set up the subtasks return task.newListr(subTasks, { concurrent: false, // no need to run concurrently since if one node is up, the rest should be up by then rendererOptions: { @@ -754,6 +764,7 @@ export class NetworkCommand extends BaseCommand { }, }; } + /** Adds the consensus node, envoy and haproxy components to remote config. */ public addNodesAndProxies(): ListrTask { return { diff --git a/src/commands/node/configs.ts b/src/commands/node/configs.ts index d3c7b50e3..9c924f113 100644 --- a/src/commands/node/configs.ts +++ b/src/commands/node/configs.ts @@ -24,8 +24,8 @@ import path from 'path'; import fs from 'fs'; import {validatePath} from '../../core/helpers.js'; import {Flags as flags} from '../flags.js'; -import {type NodeAlias, type NodeAliases, type PodName} from '../../types/aliases.js'; -import {type NetworkNodeServices} from '../../core/network_node_services.js'; +import type {NodeAlias, NodeAliases, PodName} from '../../types/aliases.js'; +import type {NetworkNodeServices} from '../../core/network_node_services.js'; export const PREPARE_UPGRADE_CONFIGS_NAME = 'prepareUpgradeConfig'; export const DOWNLOAD_GENERATED_FILES_CONFIGS_NAME = 'downloadGeneratedFilesConfig'; diff --git a/src/commands/node/tasks.ts b/src/commands/node/tasks.ts index 3e143bcc8..e0cbf0f19 100644 --- a/src/commands/node/tasks.ts +++ b/src/commands/node/tasks.ts @@ -455,6 +455,7 @@ export class NodeCommandTasks { */ _generateGossipKeys(generateMultiple: boolean) { const self = this; + return new Task( 'Generate gossip keys', (ctx: any, task: ListrTaskWrapper) => { @@ -701,7 +702,7 @@ export class NodeCommandTasks { config.stagingDir, ); - // if directory data/upgrade/current/data/keys does not exist then use data/upgrade/current + // if directory data/upgrade/current/data/keys does not exist, then use data/upgrade/current let keyDir = `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/data/keys`; if (!(await self.k8.hasDir(nodeFullyQualifiedPodName, constants.ROOT_CONTAINER, keyDir))) { keyDir = `${constants.HEDERA_HAPI_PATH}/data/upgrade/current`; diff --git a/src/core/constants.ts b/src/core/constants.ts index cfc767c1e..abe827b35 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -196,6 +196,7 @@ export const RELAY_PODS_RUNNING_MAX_ATTEMPTS = +process.env.RELAY_PODS_RUNNING_M export const RELAY_PODS_RUNNING_DELAY = +process.env.RELAY_PODS_RUNNING_DELAY || 1_000; export const RELAY_PODS_READY_MAX_ATTEMPTS = +process.env.RELAY_PODS_READY_MAX_ATTEMPTS || 100; export const RELAY_PODS_READY_DELAY = +process.env.RELAY_PODS_READY_DELAY || 1_000; +export const GRPC_PORT = +process.env.GRPC_PORT || 50_211; export const NETWORK_DESTROY_WAIT_TIMEOUT = +process.env.NETWORK_DESTROY_WAIT_TIMEOUT || 120; diff --git a/src/core/genesis_network_models/genesis_network_data_constructor.ts b/src/core/genesis_network_models/genesis_network_data_constructor.ts new file mode 100644 index 000000000..4498c6e3d --- /dev/null +++ b/src/core/genesis_network_models/genesis_network_data_constructor.ts @@ -0,0 +1,86 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import crypto from 'node:crypto'; +import {PrivateKey} from '@hashgraph/sdk'; +import {Templates} from '../templates.js'; +import {GenesisNetworkNodeDataWrapper} from './genesis_network_node_data_wrapper.js'; +import * as x509 from '@peculiar/x509'; +import * as constants from '../constants.js'; + +import type {KeyManager} from '../key_manager.js'; +import type {ToJSON} from '../../types/index.js'; +import type {JsonString, NodeAlias, NodeAliases} from '../../types/aliases.js'; + +/** + * Used to construct the nodes data and convert them to JSON + */ +export class GenesisNetworkDataConstructor implements ToJSON { + public readonly nodes: Record = {}; + + private constructor( + private readonly nodeAliases: NodeAliases, + private readonly keyManager: KeyManager, + private readonly keysDir: string, + ) { + nodeAliases.forEach((nodeAlias, nodeId) => { + // TODO: get nodeId from label in pod. + const adminPrivateKey = PrivateKey.fromStringED25519(constants.GENESIS_KEY); + const adminPubKey = adminPrivateKey.publicKey; + + this.nodes[nodeAlias] = new GenesisNetworkNodeDataWrapper(nodeId, adminPubKey, nodeAlias); + }); + } + + public static async initialize( + nodeAliases: NodeAliases, + keyManager: KeyManager, + keysDir: string, + ): Promise { + const instance = new GenesisNetworkDataConstructor(nodeAliases, keyManager, keysDir); + + await instance.load(); + + return instance; + } + + /** + * Loads the gossipCaCertificate and grpcCertificateHash + */ + private async load() { + await Promise.all( + this.nodeAliases.map(async nodeAlias => { + const nodeKeys = await this.keyManager.loadSigningKey(nodeAlias, this.keysDir); + + //* Convert the certificate to PEM format + const certPem = nodeKeys.certificate.toString(); + + //* Assign the PEM certificate + this.nodes[nodeAlias].gossipCaCertificate = nodeKeys.certificate.toString('base64'); + + //* Decode the PEM to DER format + const tlsCertDer = new Uint8Array(x509.PemConverter.decode(certPem)[0]); + + //* Generate the SHA-384 hash + this.nodes[nodeAlias].grpcCertificateHash = crypto.createHash('sha384').update(tlsCertDer).digest('base64'); + }), + ); + } + + public toJSON(): JsonString { + return JSON.stringify({nodeMetadata: Object.values(this.nodes).map(node => node.toObject())}); + } +} diff --git a/src/core/genesis_network_models/genesis_network_node_data_wrapper.ts b/src/core/genesis_network_models/genesis_network_node_data_wrapper.ts new file mode 100644 index 000000000..20183c393 --- /dev/null +++ b/src/core/genesis_network_models/genesis_network_node_data_wrapper.ts @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import type {AccountId, PublicKey} from '@hashgraph/sdk'; +import type {GenesisNetworkNodeStructure, ServiceEndpoint, ToObject} from '../../types/index.js'; + +export class GenesisNetworkNodeDataWrapper + implements GenesisNetworkNodeStructure, ToObject<{node: GenesisNetworkNodeStructure}> +{ + public accountId: AccountId; + public gossipEndpoint: ServiceEndpoint[] = []; + public serviceEndpoint: ServiceEndpoint[] = []; + public gossipCaCertificate: string; + public grpcCertificateHash: string; + public weight: number; + public readonly deleted = false; + + constructor( + public readonly nodeId: number, + public readonly adminKey: PublicKey, + public readonly description: string, + ) {} + + /** + * @param domainName - a fully qualified domain name + * @param port + */ + public addServiceEndpoint(domainName: string, port: number): void { + this.serviceEndpoint.push({domainName, port, ipAddressV4: ''}); + } + + /** + * @param domainName - a fully qualified domain name + * @param port + */ + public addGossipEndpoint(domainName: string, port: number): void { + this.gossipEndpoint.push({domainName, port, ipAddressV4: ''}); + } + + public toObject() { + return { + node: { + nodeId: this.nodeId, + accountId: this.accountId, + description: this.description, + gossipEndpoint: this.gossipEndpoint, + serviceEndpoint: this.serviceEndpoint, + gossipCaCertificate: this.gossipCaCertificate, + grpcCertificateHash: this.grpcCertificateHash, + weight: this.weight, + deleted: this.deleted, + adminKey: this.adminKey, + }, + }; + } +} diff --git a/src/core/helpers.ts b/src/core/helpers.ts index 4d83c2c8a..889fb56ee 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -27,6 +27,7 @@ import {type NodeAlias, type NodeAliases} from '../types/aliases.js'; import {type CommandFlag} from '../types/flag_types.js'; import {type SoloLogger} from './logging.js'; import {type Duration} from './time/duration.js'; +import {type NodeAddConfigClass} from '../commands/node/configs.js'; export function sleep(duration: Duration) { return new Promise(resolve => { @@ -241,13 +242,14 @@ export function addDebugOptions(valuesArg: string, debugNodeAlias: NodeAlias, in export function addSaveContextParser(ctx: any) { const exportedCtx = {} as Record; - const config = /** @type {NodeAddConfigClass} **/ ctx.config; + const config = ctx.config as NodeAddConfigClass; const exportedFields = ['tlsCertHash', 'upgradeZipHash', 'newNode']; exportedCtx.signingCertDer = ctx.signingCertDer.toString(); exportedCtx.gossipEndpoints = ctx.gossipEndpoints.map((ep: any) => `${ep.getDomainName}:${ep.getPort}`); exportedCtx.grpcServiceEndpoints = ctx.grpcServiceEndpoints.map((ep: any) => `${ep.getDomainName}:${ep.getPort}`); exportedCtx.adminKey = ctx.adminKey.toString(); + // @ts-ignore exportedCtx.existingNodeAliases = config.existingNodeAliases; for (const prop of exportedFields) { @@ -308,16 +310,14 @@ export function prepareEndpoints(endpointType: string, endpoints: string[], defa if (endpointType.toUpperCase() === constants.ENDPOINT_TYPE_IP) { ret.push( new ServiceEndpoint({ - // @ts-ignore - port, + port: +port, ipAddressV4: parseIpAddressToUint8Array(url), }), ); } else { ret.push( new ServiceEndpoint({ - // @ts-ignore - port, + port: +port, domainName: url, }), ); diff --git a/src/core/profile_manager.ts b/src/core/profile_manager.ts index cf0d89923..61a07d790 100644 --- a/src/core/profile_manager.ts +++ b/src/core/profile_manager.ts @@ -20,7 +20,6 @@ import {SoloError, IllegalArgumentError, MissingArgumentError} from './errors.js import * as yaml from 'yaml'; import dot from 'dot-object'; import * as semver from 'semver'; -import type {SemVer} from 'semver'; import {readFile, writeFile} from 'fs/promises'; import {Flags as flags} from '../commands/flags.js'; @@ -29,8 +28,12 @@ import * as constants from './constants.js'; import {type ConfigManager} from './config_manager.js'; import * as helpers from './helpers.js'; import {getNodeAccountMap} from './helpers.js'; +import {AccountId} from '@hashgraph/sdk'; +import type {SemVer} from 'semver'; import type {SoloLogger} from './logging.js'; -import type {NodeAlias, NodeAliases} from '../types/aliases.js'; +import type {AnyObject, DirPath, NodeAlias, NodeAliases, Path} from '../types/aliases.js'; +import type {GenesisNetworkDataConstructor} from './genesis_network_models/genesis_network_data_constructor.js'; +import type {Optional} from '../types/index.js'; const consensusSidecars = [ 'recordStreamUploader', @@ -43,12 +46,12 @@ const consensusSidecars = [ export class ProfileManager { private readonly logger: SoloLogger; private readonly configManager: ConfigManager; - private readonly cacheDir: string; + private readonly cacheDir: DirPath; - private profiles: Map; - private profileFile: string | undefined; + private profiles: Map; + private profileFile: Optional; - constructor(logger: SoloLogger, configManager: ConfigManager, cacheDir: string = constants.SOLO_VALUES_DIR) { + constructor(logger: SoloLogger, configManager: ConfigManager, cacheDir: DirPath = constants.SOLO_VALUES_DIR) { if (!logger) throw new MissingArgumentError('An instance of core/SoloLogger is required'); if (!configManager) throw new MissingArgumentError('An instance of core/ConfigManager is required'); @@ -61,7 +64,15 @@ export class ProfileManager { this.cacheDir = cacheDir; } - loadProfiles(forceReload = false): Map { + /** + * Load profiles from a profile file and populate the profiles map. + * + * @param [forceReload = false] - forces the profiles map to override even if it exists. + * @returns reference to the populated profiles map. + * + * @throws {IllegalArgumentError} if the profile file is not found. + */ + loadProfiles(forceReload = false): Map { const profileFile = this.configManager.getFlag(flags.profileFile); if (!profileFile) throw new MissingArgumentError('profileFile is required'); @@ -75,7 +86,7 @@ export class ProfileManager { // load profile file this.profiles = new Map(); const yamlData = fs.readFileSync(profileFile, 'utf8'); - const profileItems = yaml.parse(yamlData) as Record; + const profileItems = yaml.parse(yamlData) as Record; // add profiles for (const key in profileItems) { @@ -88,26 +99,36 @@ export class ProfileManager { return this.profiles; } - getProfile(profileName: string): object { + /** + * Get profile from the profiles map, loads them on demand if they are not loaded already. + * + * @param profileName - profile name (key in the map). + * @returns the profile. + * + * @throws {IllegalArgumentError} if profiles can't be loaded or the profile name is not found in the map. + */ + getProfile(profileName: string): AnyObject { if (!profileName) throw new MissingArgumentError('profileName is required'); if (!this.profiles || this.profiles.size <= 0) { this.loadProfiles(); } - if (!this.profiles || !this.profiles.has(profileName)) + if (!this.profiles || !this.profiles.has(profileName)) { throw new IllegalArgumentError(`Profile does not exists with name: ${profileName}`); - return this.profiles.get(profileName) as object; + } + + return this.profiles.get(profileName) as AnyObject; } /** - * Set value in the yaml object + * 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 + * @param yamlRoot - root of the YAML object * @returns */ - _setValue(itemPath: string, value: any, yamlRoot: object): object { - // find the location where to set the value in the yaml + _setValue(itemPath: string, value: any, yamlRoot: AnyObject): AnyObject { + // find the location where to set the value in the YAML const itemPathParts: string[] = itemPath.split('.'); let parent = yamlRoot; let current = parent; @@ -115,7 +136,7 @@ export class ProfileManager { for (let itemPathPart of itemPathParts) { if (helpers.isNumeric(itemPathPart)) { // @ts-ignore - itemPathPart = Number.parseInt(itemPathPart); // numeric path part can only be array index i.e. an integer + itemPathPart = Number.parseInt(itemPathPart); // numeric path part can only be array index i.e., an integer if (!Array.isArray(parent[prevItemPath])) { parent[prevItemPath] = []; } @@ -144,12 +165,12 @@ export class ProfileManager { /** * Set items for the chart - * @param itemPath - item path in the yaml, if empty then root of the yaml object will be used + * @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 + * @param yamlRoot - root of the YAML object to update * @private */ - _setChartItems(itemPath: string, items: any, yamlRoot: object) { + _setChartItems(itemPath: string, items: any, yamlRoot: AnyObject) { if (!items) return; const dotItems = dot.dot(items); @@ -157,7 +178,7 @@ export class ProfileManager { for (const key in dotItems) { let itemKey = key; - // if it is an array key like extraEnv[0].JAVA_OPTS, convert it into dot separated key as extraEnv.0.JAVA_OPTS + // if it is an array key like extraEnv[0].JAVA_OPTS, convert it into a dot separated key as extraEnv.0.JAVA_OPTS if (key.indexOf('[') !== -1) { itemKey = key.replace('[', '.').replace(']', ''); } @@ -170,7 +191,12 @@ export class ProfileManager { } } - resourcesForConsensusPod(profile: any, nodeAliases: NodeAliases, yamlRoot: object): object { + resourcesForConsensusPod( + profile: AnyObject, + nodeAliases: NodeAliases, + yamlRoot: AnyObject, + genesisNetworkData?: GenesisNetworkDataConstructor, + ): AnyObject { if (!profile) throw new MissingArgumentError('profile is required'); const accountMap = getNodeAccountMap(nodeAliases); @@ -197,6 +223,7 @@ export class ProfileManager { this.configManager.getFlag(flags.releaseTag), this.configManager.getFlag(flags.app), this.configManager.getFlag(flags.chainId), + genesisNetworkData, ); for (const flag of flags.nodeConfigFileFlags.values()) { @@ -238,6 +265,15 @@ export class ProfileManager { path.join(stagingDir, 'templates', 'bootstrap.properties'), yamlRoot, ); + + if (genesisNetworkData) { + const genesisNetworkJson = path.join(stagingDir, 'genesis-network.json'); + + fs.writeFileSync(genesisNetworkJson, genesisNetworkData.toJSON()); + + this._setFileContentsAsValue('hedera.configMaps.genesisNetworkJson', genesisNetworkJson, yamlRoot); + } + if (this.configManager.getFlag(flags.applicationEnv)) { this._setFileContentsAsValue( 'hedera.configMaps.applicationEnv', @@ -259,26 +295,26 @@ export class ProfileManager { return yamlRoot; } - resourcesForHaProxyPod(profile: any, yamlRoot: object) { + private resourcesForHaProxyPod(profile: AnyObject, yamlRoot: AnyObject) { if (!profile) throw new MissingArgumentError('profile is required'); if (!profile.haproxy) return; // use chart defaults return this._setChartItems('defaults.haproxy', profile.haproxy, yamlRoot); } - resourcesForEnvoyProxyPod(profile: any, yamlRoot: object) { + private resourcesForEnvoyProxyPod(profile: AnyObject, yamlRoot: AnyObject) { if (!profile) throw new MissingArgumentError('profile is required'); if (!profile.envoyProxy) return; // use chart defaults return this._setChartItems('defaults.envoyProxy', profile.envoyProxy, yamlRoot); } - resourcesForHederaExplorerPod(profile: any, yamlRoot: object) { + private resourcesForHederaExplorerPod(profile: AnyObject, yamlRoot: AnyObject) { if (!profile) throw new MissingArgumentError('profile is required'); if (!profile.explorer) return; return this._setChartItems('', profile.explorer, yamlRoot); } - resourcesForMinioTenantPod(profile: any, yamlRoot: object) { + private resourcesForMinioTenantPod(profile: AnyObject, yamlRoot: AnyObject) { if (!profile) throw new MissingArgumentError('profile is required'); // @ts-ignore if (!profile.minio || !profile.minio.tenant) return; // use chart defaults @@ -299,37 +335,29 @@ export class ProfileManager { /** * Prepare a values file for Solo Helm chart - * @param profileName resource profile name + * @param profileName - resource profile name + * @param genesisNetworkData - reference to the constructor * @returns return the full path to the values file */ - prepareValuesForSoloChart(profileName: string) { + public async prepareValuesForSoloChart(profileName: string, genesisNetworkData?: GenesisNetworkDataConstructor) { if (!profileName) throw new MissingArgumentError('profileName is required'); const profile = this.getProfile(profileName); const nodeAliases = helpers.parseNodeAliases(this.configManager.getFlag(flags.nodeAliasesUnparsed)); if (!nodeAliases) throw new SoloError('Node IDs are not set in the config'); - // generate the yaml + // generate the YAML const yamlRoot = {}; - this.resourcesForConsensusPod(profile, nodeAliases, yamlRoot); + this.resourcesForConsensusPod(profile, nodeAliases, yamlRoot, genesisNetworkData); this.resourcesForHaProxyPod(profile, yamlRoot); this.resourcesForEnvoyProxyPod(profile, yamlRoot); this.resourcesForMinioTenantPod(profile, yamlRoot); - // write the yaml const cachedValuesFile = path.join(this.cacheDir, `solo-${profileName}.yaml`); - return new Promise((resolve, reject) => { - fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), err => { - if (err) { - reject(err); - } - - resolve(cachedValuesFile); - }); - }); + return this.writeToYaml(cachedValuesFile, yamlRoot); } - async bumpHederaConfigVersion(applicationPropertiesPath: string) { + private async bumpHederaConfigVersion(applicationPropertiesPath: string) { const lines = (await readFile(applicationPropertiesPath, 'utf-8')).split('\n'); for (const line of lines) { @@ -343,23 +371,14 @@ export class ProfileManager { await writeFile(applicationPropertiesPath, lines.join('\n')); } - async prepareValuesForNodeAdd(configTxtPath: string, applicationPropertiesPath: string) { + public async prepareValuesForNodeAdd(configTxtPath: string, applicationPropertiesPath: string) { const yamlRoot = {}; this._setFileContentsAsValue('hedera.configMaps.configTxt', configTxtPath, yamlRoot); await this.bumpHederaConfigVersion(applicationPropertiesPath); this._setFileContentsAsValue('hedera.configMaps.applicationProperties', applicationPropertiesPath, yamlRoot); - // write the yaml const cachedValuesFile = path.join(this.cacheDir, 'solo-node-add.yaml'); - return new Promise((resolve, reject) => { - fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), err => { - if (err) { - reject(err); - } - - resolve(cachedValuesFile); - }); - }); + return this.writeToYaml(cachedValuesFile, yamlRoot); } /** @@ -367,38 +386,38 @@ export class ProfileManager { * @param profileName - resource profile name * @returns return the full path to the values file */ - prepareValuesForRpcRelayChart(profileName: string) { + public async prepareValuesForRpcRelayChart(profileName: string) { if (!profileName) throw new MissingArgumentError('profileName is required'); - const profile = this.getProfile(profileName) as any; + const profile = this.getProfile(profileName) as AnyObject; if (!profile.rpcRelay) return Promise.resolve(); // use chart defaults - // generate the yaml + // generate the YAML const yamlRoot = {}; this._setChartItems('', profile.rpcRelay, yamlRoot); - // write the yaml const cachedValuesFile = path.join(this.cacheDir, `rpcRelay-${profileName}.yaml`); - return new Promise((resolve, reject) => { - fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), err => { - if (err) { - reject(err); - } - - resolve(cachedValuesFile); - }); - }); + return this.writeToYaml(cachedValuesFile, yamlRoot); } - prepareValuesHederaExplorerChart(profileName: string) { + public async prepareValuesHederaExplorerChart(profileName: string) { if (!profileName) throw new MissingArgumentError('profileName is required'); - const profile = this.getProfile(profileName) as any; - // generate the yaml + const profile = this.getProfile(profileName) as AnyObject; + // generate the YAML const yamlRoot = {}; this.resourcesForHederaExplorerPod(profile, yamlRoot); - // write the yaml const cachedValuesFile = path.join(this.cacheDir, `explorer-${profileName}.yaml`); - return new Promise((resolve, reject) => { + 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. + */ + private async writeToYaml(cachedValuesFile: Path, yamlRoot: AnyObject) { + return await new Promise((resolve, reject) => { fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), err => { if (err) { reject(err); @@ -412,14 +431,14 @@ export class ProfileManager { /** * Prepare a values file for mirror-node Helm chart * @param profileName - resource profile name - * @returns return the full path to the values file + * @returns the full path to the values file */ - prepareValuesForMirrorNodeChart(profileName: string) { + public async prepareValuesForMirrorNodeChart(profileName: string) { if (!profileName) throw new MissingArgumentError('profileName is required'); - const profile = this.getProfile(profileName) as any; + const profile = this.getProfile(profileName) as AnyObject; if (!profile.mirror) return Promise.resolve(); // use chart defaults - // generate the yaml + // generate the YAML const yamlRoot = {}; if (profile.mirror.postgresql) { if (profile.mirror.postgresql.persistence) { @@ -435,26 +454,17 @@ export class ProfileManager { this._setChartItems('grpc', profile.mirror.grpc, yamlRoot); this._setChartItems('monitor', profile.mirror.monitor, yamlRoot); - // write the yaml const cachedValuesFile = path.join(this.cacheDir, `mirror-${profileName}.yaml`); - return new Promise((resolve, reject) => { - fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), err => { - if (err) { - reject(err); - } - - resolve(cachedValuesFile); - }); - }); + return this.writeToYaml(cachedValuesFile, yamlRoot); } /** - * 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 + * 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: object) { + private _setFileContentsAsValue(itemPath: string, valueFilePath: string, yamlRoot: AnyObject) { const fileContents = fs.readFileSync(valueFilePath, 'utf8'); this._setValue(itemPath, fileContents, yamlRoot); } @@ -467,6 +477,7 @@ export class ProfileManager { * @param releaseTag - release tag e.g. v0.42.0 * @param [appName] - the app name (default: HederaNode.jar) * @param [chainId] - chain ID (298 for local network) + * @param genesisNetworkData * @returns the config.txt file path */ prepareConfigTxt( @@ -476,17 +487,21 @@ export class ProfileManager { releaseTag: string, appName = constants.HEDERA_APP_NAME, chainId = constants.HEDERA_CHAIN_ID, + genesisNetworkData?: GenesisNetworkDataConstructor, ) { - if (!nodeAccountMap || nodeAccountMap.size === 0) + if (!nodeAccountMap || nodeAccountMap.size === 0) { throw new MissingArgumentError('nodeAccountMap the map of node IDs to account IDs is required'); + } + if (!releaseTag) throw new MissingArgumentError('release tag is required'); - if (!fs.existsSync(destPath)) + if (!fs.existsSync(destPath)) { throw new IllegalArgumentError(`config destPath does not exist: ${destPath}`, destPath); + } // init variables - const internalPort = constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT; - const externalPort = constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT; + const internalPort = +constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT; + const externalPort = +constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT; const nodeStakeAmount = constants.HEDERA_NODE_DEFAULT_STAKE_AMOUNT; // @ts-ignore @@ -501,8 +516,25 @@ export class ProfileManager { for (const nodeAlias of nodeAccountMap.keys()) { const internalIP = Templates.renderFullyQualifiedNetworkPodName(namespace, nodeAlias); const externalIP = Templates.renderFullyQualifiedNetworkSvcName(namespace, nodeAlias); - const account = nodeAccountMap.get(nodeAlias); + + if (genesisNetworkData) { + // TODO: Use the "nodeSeq" + + const nodeDataWrapper = genesisNetworkData.nodes[nodeAlias]; + + nodeDataWrapper.weight = nodeStakeAmount; + nodeDataWrapper.accountId = AccountId.fromString(account); + + //? Add gossip endpoints + nodeDataWrapper.addGossipEndpoint(externalIP, externalPort); + + const haProxyFqdn = Templates.renderFullyQualifiedHaProxyName(nodeAlias, namespace); + + //? Add service endpoints + nodeDataWrapper.addServiceEndpoint(haProxyFqdn, constants.GRPC_PORT); + } + configLines.push( `address, ${nodeSeq}, ${nodeSeq}, ${nodeAlias}, ${nodeStakeAmount}, ${internalIP}, ${internalPort}, ${externalIP}, ${externalPort}, ${account}`, ); @@ -519,7 +551,7 @@ export class ProfileManager { fs.writeFileSync(configFilePath, configLines.join('\n')); return configFilePath; - } catch (e: Error | any) { + } catch (e: Error | unknown) { throw new SoloError('failed to generate config.txt', e); } } diff --git a/src/core/templates.ts b/src/core/templates.ts index 17a65eab0..a4274bd59 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -23,7 +23,7 @@ import {type AccountId} from '@hashgraph/sdk'; import type {IP, NodeAlias, NodeId, PodName} from '../types/aliases.js'; import {GrpcProxyTlsEnums} from './enumerations.js'; import {type ContextClusterStructure} from '../types/config_types.js'; -import type {Cluster, Context} from './config/remote/types.js'; +import type {Cluster, Context, Namespace} from './config/remote/types.js'; export class Templates { public static renderNetworkPodName(nodeAlias: NodeAlias): PodName { @@ -263,6 +263,10 @@ export class Templates { return `haproxy-${nodeAlias}`; } + public static renderFullyQualifiedHaProxyName(nodeAlias: NodeAlias, namespace: Namespace): string { + return `${Templates.renderHaProxyName(nodeAlias)}-svc.${namespace}.svc.cluster.local`; + } + public static parseNodeAliasToIpMapping(unparsed: string): Record { const mapping: Record = {}; diff --git a/src/types/aliases.ts b/src/types/aliases.ts index 40c6337f0..a4cfffe7d 100644 --- a/src/types/aliases.ts +++ b/src/types/aliases.ts @@ -39,6 +39,16 @@ export type TaskFunction = ( export type ConfigBuilder = (argv, ctx, task, shouldLoadNodeClient?) => Promise; +export type Nullable = T | null; + export type IP = string; +export type JsonString = string; + +export type Path = string; +export type FilePath = string; +export type DirPath = string; + +export type AnyObject = Record; + export type SdkNetworkEndpoint = `${string}:${number}`; diff --git a/src/types/index.ts b/src/types/index.ts index 655a49334..be22a2a13 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,7 +18,9 @@ import type * as x509 from '@peculiar/x509'; import type net from 'net'; import type * as WebSocket from 'ws'; import type crypto from 'crypto'; -import type {ListrTask} from 'listr2'; +import type {ListrTask, ListrTaskWrapper} from 'listr2'; +import type {AccountId, PublicKey} from '@hashgraph/sdk'; +import type {JsonString} from './aliases.js'; // NOTE: DO NOT add any Solo imports in this file to avoid circular dependencies @@ -77,6 +79,39 @@ export interface ToObject { toObject(): T; } +/** + * Interface for converting class to JSON string. + */ +export interface ToJSON { + /** + * Converts the class instance to a plain JSON string. + * + * @returns the plain JSON string of the class. + */ + toJSON(): JsonString; +} + export type SoloListrTask = ListrTask; export type EmptyContextConfig = object; + +export type SoloListrTaskWrapper = ListrTaskWrapper; + +export interface ServiceEndpoint { + ipAddressV4?: string; + port: number; + domainName: string; +} + +export interface GenesisNetworkNodeStructure { + nodeId: number; + accountId: AccountId; + description: string; + gossipEndpoint: ServiceEndpoint[]; + serviceEndpoint: ServiceEndpoint[]; + gossipCaCertificate: string; + grpcCertificateHash: string; + weight: number; + deleted: boolean; + adminKey: PublicKey; +} diff --git a/test/data/local-config.yaml b/test/data/local-config.yaml index fdac33ca3..ad1342c1f 100644 --- a/test/data/local-config.yaml +++ b/test/data/local-config.yaml @@ -6,4 +6,4 @@ deployments: currentDeploymentName: deployment-1 clusterContextMapping: cluster-1: context-1 - cluster-2: context-2 \ No newline at end of file + cluster-2: context-2 diff --git a/test/unit/commands/network.test.ts b/test/unit/commands/network.test.ts index 86fea8b34..57a0988c7 100644 --- a/test/unit/commands/network.test.ts +++ b/test/unit/commands/network.test.ts @@ -40,6 +40,7 @@ import {ProfileManager} from '../../../src/core/profile_manager.js'; import {KeyManager} from '../../../src/core/key_manager.js'; import {ROOT_DIR} from '../../../src/core/constants.js'; import {ListrLease} from '../../../src/core/lease/listr_lease.js'; +import {GenesisNetworkDataConstructor} from '../../../src/core/genesis_network_models/genesis_network_data_constructor.js'; const getBaseCommandOpts = () => ({ logger: sinon.stub(), @@ -112,6 +113,8 @@ describe('NetworkCommand unit tests', () => { opts.configManager = new ConfigManager(opts.logger); opts.leaseManager = new LeaseManager(opts.k8, opts.configManager, opts.logger, new IntervalLeaseRenewalService()); opts.leaseManager.currentNamespace = sinon.stub().returns(testName); + + GenesisNetworkDataConstructor.initialize = sinon.stub().returns(null); }); it('Install function is called with expected parameters', async () => { diff --git a/tsconfig.json b/tsconfig.json index ec1fa8b60..5832eae0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig-google.json", "compilerOptions": { + "isolatedModules": true, "target": "ES2022", "lib": [ "ES2022",