@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
460 lines (392 loc) • 14.9 kB
text/typescript
/**
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import util from 'util';
import * as semver from 'semver';
import {SoloError} from './errors.js';
import {Templates} from './templates.js';
import * as constants from './constants.js';
import {PrivateKey, ServiceEndpoint} from '@hashgraph/sdk';
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/node_add_config.js';
import {type ConsensusNode} from './model/consensus_node.js';
import {type Optional} from '../types/index.js';
import {type Version} from './config/remote/types.js';
import {fileURLToPath} from 'url';
import {NamespaceName} from './kube/resources/namespace/namespace_name.js';
import {type K8} from './kube/k8.js';
export function getInternalIp(releaseVersion: semver.SemVer, namespaceName: NamespaceName, nodeAlias: NodeAlias) {
//? Explanation: for v0.59.x the internal IP address is set to 127.0.0.1 to avoid an ISS
let internalIp = '';
// for versions that satisfy 0.58.5+
// @ts-expect-error TS2353: Object literal may only specify known properties
if (semver.gte(releaseVersion, '0.58.5', {includePrerelease: true})) {
internalIp = '127.0.0.1';
}
// versions less than 0.58.5
else {
internalIp = Templates.renderFullyQualifiedNetworkPodName(namespaceName, nodeAlias);
}
return internalIp;
}
export async function getExternalAddress(
consensusNode: ConsensusNode,
k8: K8,
useLoadBalancer: boolean,
): Promise<string> {
if (useLoadBalancer) {
return resolveLoadBalancerAddress(consensusNode, k8);
}
return consensusNode.fullyQualifiedDomainName;
}
async function resolveLoadBalancerAddress(consensusNode: ConsensusNode, k8: K8): Promise<string> {
const ns = NamespaceName.of(consensusNode.namespace);
const serviceList = await k8
.services()
.list(ns, [`solo.hedera.com/node-id=${consensusNode.nodeId},solo.hedera.com/type=network-node-svc`]);
if (serviceList && serviceList.length > 0) {
const svc = serviceList[0];
if (!svc.metadata.name.startsWith('network-node')) {
throw new SoloError(`Service found is not a network node service: ${svc.metadata.name}`);
}
if (svc.status?.loadBalancer?.ingress && svc.status.loadBalancer.ingress.length > 0) {
for (let i = 0; i < svc.status.loadBalancer.ingress.length; i++) {
const ingress = svc.status.loadBalancer.ingress[i];
if (ingress.hostname) {
return ingress.hostname;
} else if (ingress.ip) {
return ingress.ip;
}
}
}
}
return consensusNode.fullyQualifiedDomainName;
}
export function sleep(duration: Duration) {
return new Promise<void>(resolve => {
setTimeout(resolve, duration.toMillis());
});
}
export function parseNodeAliases(input: string): NodeAliases {
return splitFlagInput(input, ',') as NodeAliases;
}
export function splitFlagInput(input: string, separator = ',') {
if (!input) {
return [];
} else if (typeof input !== 'string') {
throw new SoloError(`input [input='${input}'] is not a comma separated string`);
}
return input
.split(separator)
.map(s => s.trim())
.filter(Boolean);
}
/**
* @param arr - The array to be cloned
* @returns a new array with the same elements as the input array
*/
export function cloneArray<T>(arr: T[]): T[] {
return JSON.parse(JSON.stringify(arr));
}
export function getTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'solo-'));
}
export function createBackupDir(destDir: string, prefix = 'backup', curDate = new Date()) {
const dateDir = util.format(
'%s%s%s_%s%s%s',
curDate.getFullYear(),
curDate.getMonth().toString().padStart(2, '0'),
curDate.getDate().toString().padStart(2, '0'),
curDate.getHours().toString().padStart(2, '0'),
curDate.getMinutes().toString().padStart(2, '0'),
curDate.getSeconds().toString().padStart(2, '0'),
);
const backupDir = path.join(destDir, prefix, dateDir);
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, {recursive: true});
}
return backupDir;
}
export function makeBackup(fileMap = new Map<string, string>(), removeOld = true) {
for (const entry of fileMap) {
const srcPath = entry[0];
const destPath = entry[1];
if (fs.existsSync(srcPath)) {
fs.cpSync(srcPath, destPath);
if (removeOld) {
fs.rmSync(srcPath);
}
}
}
}
export function backupOldTlsKeys(nodeAliases: NodeAliases, keysDir: string, curDate = new Date(), dirPrefix = 'tls') {
const backupDir = createBackupDir(keysDir, `unused-${dirPrefix}`, curDate);
const fileMap = new Map<string, string>();
for (const nodeAlias of nodeAliases) {
const srcPath = path.join(keysDir, Templates.renderTLSPemPrivateKeyFile(nodeAlias));
const destPath = path.join(backupDir, Templates.renderTLSPemPrivateKeyFile(nodeAlias));
fileMap.set(srcPath, destPath);
}
makeBackup(fileMap, true);
return backupDir;
}
export function backupOldPemKeys(
nodeAliases: NodeAliases,
keysDir: string,
curDate = new Date(),
dirPrefix = 'gossip-pem',
) {
const backupDir = createBackupDir(keysDir, `unused-${dirPrefix}`, curDate);
const fileMap = new Map<string, string>();
for (const nodeAlias of nodeAliases) {
const srcPath = path.join(keysDir, Templates.renderGossipPemPrivateKeyFile(nodeAlias));
const destPath = path.join(backupDir, Templates.renderGossipPemPrivateKeyFile(nodeAlias));
fileMap.set(srcPath, destPath);
}
makeBackup(fileMap, true);
return backupDir;
}
export function isNumeric(str: string) {
if (typeof str !== 'string') return false; // we only process strings!
return (
!isNaN(str as any) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
!isNaN(parseFloat(str))
); // ...and ensure strings of whitespace fail
}
/**
* Validate a path provided by the user to prevent path traversal attacks
* @param input - the input provided by the user
* @returns a validated path
*/
export function validatePath(input: string) {
if (input.indexOf('\0') !== -1) {
throw new SoloError(`access denied for path: ${input}`);
}
return input;
}
/**
* Create a map of node aliases to account IDs
* @param nodeAliases
* @returns the map of node IDs to account IDs
*/
export function getNodeAccountMap(nodeAliases: NodeAliases) {
const accountMap = new Map<NodeAlias, string>();
const realm = constants.HEDERA_NODE_ACCOUNT_ID_START.realm;
const shard = constants.HEDERA_NODE_ACCOUNT_ID_START.shard;
let accountId = constants.HEDERA_NODE_ACCOUNT_ID_START.num;
nodeAliases.forEach(nodeAlias => {
const nodeAccount = `${realm}.${shard}.${accountId}`;
accountId = accountId.add(1);
accountMap.set(nodeAlias, nodeAccount);
});
return accountMap;
}
export function getEnvValue(envVarArray: string[], name: string) {
const kvPair = envVarArray.find(v => v.startsWith(`${name}=`));
return kvPair ? kvPair.split('=')[1] : null;
}
export function parseIpAddressToUint8Array(ipAddress: string) {
const parts = ipAddress.split('.');
const uint8Array = new Uint8Array(4);
for (let i = 0; i < 4; i++) {
uint8Array[i] = parseInt(parts[i], 10);
}
return uint8Array;
}
/** If the basename of the src did not match expected basename, rename it first, then copy to destination */
export function renameAndCopyFile(srcFilePath: string, expectedBaseName: string, destDir: string, logger: SoloLogger) {
const srcDir = path.dirname(srcFilePath);
if (path.basename(srcFilePath) !== expectedBaseName) {
fs.renameSync(srcFilePath, path.join(srcDir, expectedBaseName));
}
// copy public key and private key to key directory
fs.copyFile(path.join(srcDir, expectedBaseName), path.join(destDir, expectedBaseName), err => {
if (err) {
// @ts-ignore
logger.error(`Error copying file: ${err.message}`);
throw new SoloError(`Error copying file: ${err.message}`);
}
});
}
/**
* Add debug options to valuesArg used by helm chart
* @param valuesArg the valuesArg to update
* @param debugNodeAlias the node ID to attach the debugger to
* @param index the index of extraEnv to add the debug options to
* @returns updated valuesArg
*/
export function addDebugOptions(valuesArg: string, debugNodeAlias: NodeAlias, index = 0) {
if (debugNodeAlias) {
const nodeId = Templates.nodeIdFromNodeAlias(debugNodeAlias);
valuesArg += ` --set "hedera.nodes[${nodeId}].root.extraEnv[${index}].name=JAVA_OPTS"`;
valuesArg += ` --set "hedera.nodes[${nodeId}].root.extraEnv[${index}].value=-agentlib:jdwp=transport=dt_socket\\,server=y\\,suspend=y\\,address=*:${constants.JVM_DEBUG_PORT}"`;
}
return valuesArg;
}
/**
* Returns an object that can be written to a file without data loss.
* Contains fields needed for adding a new node through separate commands
* @param ctx
* @returns file writable object
*/
export function addSaveContextParser(ctx: any) {
const exportedCtx = {} as Record<string, string>;
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) {
exportedCtx[prop] = ctx[prop];
}
return exportedCtx;
}
/**
* Initializes objects in the context from a provided string
* Contains fields needed for adding a new node through separate commands
* @param ctx - accumulator object
* @param ctxData - data in string format
* @returns file writable object
*/
export function addLoadContextParser(ctx: any, ctxData: any) {
const config: any = ctx.config;
ctx.signingCertDer = new Uint8Array(ctxData.signingCertDer.split(','));
ctx.gossipEndpoints = prepareEndpoints(
ctx.config.endpointType,
ctxData.gossipEndpoints,
constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT,
);
ctx.grpcServiceEndpoints = prepareEndpoints(
ctx.config.endpointType,
ctxData.grpcServiceEndpoints,
constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT,
);
ctx.adminKey = PrivateKey.fromStringED25519(ctxData.adminKey);
config.nodeAlias = ctxData.newNode.name;
config.existingNodeAliases = ctxData.existingNodeAliases;
config.allNodeAliases = [...config.existingNodeAliases, ctxData.newNode.name];
const fieldsToImport = ['tlsCertHash', 'upgradeZipHash', 'newNode'];
for (const prop of fieldsToImport) {
ctx[prop] = ctxData[prop];
}
}
export function prepareEndpoints(endpointType: string, endpoints: string[], defaultPort: number | string) {
const ret: ServiceEndpoint[] = [];
for (const endpoint of endpoints) {
const parts = endpoint.split(':');
let url = '';
let port = defaultPort;
if (parts.length === 2) {
url = parts[0].trim();
port = +parts[1].trim();
} else if (parts.length === 1) {
url = parts[0];
} else {
throw new SoloError(`incorrect endpoint format. expected url:port, found ${endpoint}`);
}
if (endpointType.toUpperCase() === constants.ENDPOINT_TYPE_IP) {
ret.push(
new ServiceEndpoint({
port: +port,
ipAddressV4: parseIpAddressToUint8Array(url),
}),
);
} else {
ret.push(
new ServiceEndpoint({
port: +port,
domainName: url,
}),
);
}
}
return ret;
}
/** Adds all the types of flags as properties on the provided argv object */
export function addFlagsToArgv(
argv: any,
flags: {
requiredFlags: CommandFlag[];
requiredFlagsWithDisabledPrompt: CommandFlag[];
optionalFlags: CommandFlag[];
},
) {
argv.requiredFlags = flags.requiredFlags;
argv.requiredFlagsWithDisabledPrompt = flags.requiredFlagsWithDisabledPrompt;
argv.optionalFlags = flags.optionalFlags;
return argv;
}
export function resolveValidJsonFilePath(filePath: string, defaultPath?: string): string {
if (!filePath) {
if (defaultPath) {
return resolveValidJsonFilePath(defaultPath, null);
}
return '';
}
const resolvedFilePath = fs.realpathSync(validatePath(filePath));
if (!fs.existsSync(resolvedFilePath)) {
if (defaultPath) {
return resolveValidJsonFilePath(defaultPath, null);
}
throw new SoloError(`File does not exist: ${filePath}`);
}
// If the file is empty (or size cannot be determined) then fallback on the default values
const throttleInfo = fs.statSync(resolvedFilePath);
if (throttleInfo.size === 0 && defaultPath) {
return resolveValidJsonFilePath(defaultPath, null);
} else if (throttleInfo.size === 0) {
throw new SoloError(`File is empty: ${filePath}`);
}
try {
// Ensure the file contains valid JSON data
JSON.parse(fs.readFileSync(resolvedFilePath, 'utf8'));
return resolvedFilePath;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e: unknown) {
// Fallback to the default values if an error occurs due to invalid JSON data or unable to read the file size
if (defaultPath) {
return resolveValidJsonFilePath(defaultPath, null);
}
throw new SoloError(`Invalid JSON data in file: ${filePath}`);
}
}
export function populateHelmArgs(valuesMapping: Record<string, string | boolean | number>): string {
let valuesArg = '';
for (const [key, value] of Object.entries(valuesMapping)) {
valuesArg += ` --set ${key}=${value}`;
}
return valuesArg;
}
/**
* @param nodeAlias
* @param consensusNodes
* @returns context of the node
*/
export function extractContextFromConsensusNodes(
nodeAlias: NodeAlias,
consensusNodes?: ConsensusNode[],
): Optional<string> {
if (!consensusNodes) return undefined;
if (!consensusNodes.length) return undefined;
const consensusNode = consensusNodes.find(node => node.name === nodeAlias);
return consensusNode ? consensusNode.context : undefined;
}
export function getSoloVersion(): Version {
if (process.env.npm_package_version) {
return process.env.npm_package_version;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJsonPath = path.resolve(__dirname, '../../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version;
}