@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
898 lines (779 loc) • 30.8 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import fs, {type Stats} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {format} from 'node:util';
import {SoloError} from './errors/solo-error.js';
import {Templates} from './templates.js';
import * as constants from './constants.js';
import {PrivateKey, ServiceEndpoint, type Long} from '@hiero-ledger/sdk';
import {type AnyYargs, type AnyListrContext, type NodeAlias, type NodeAliases} from '../types/aliases.js';
import {type CommandFlag} from '../types/flag-types.js';
import {type SoloLogger} from './logging/solo-logger.js';
import {type Duration} from './time/duration.js';
import {type NodeAddConfigClass} from '../commands/node/config-interfaces/node-add-config-class.js';
import {type ConsensusNode} from './model/consensus-node.js';
import {
type ClusterReferenceName,
type ClusterReferences,
type Optional,
type ReleaseNameData,
} from '../types/index.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {type K8Factory} from '../integration/kube/k8-factory.js';
import chalk from 'chalk';
import {PathEx} from '../business/utils/path-ex.js';
import {type ConfigManager} from './config-manager.js';
import {Flags, Flags as flags} from '../commands/flags.js';
import {type Realm, type Shard} from './../types/index.js';
import {execSync} from 'node:child_process';
import {type Pod} from '../integration/kube/resources/pod/pod.js';
import yaml from 'yaml';
import {type ConfigMap} from '../integration/kube/resources/config-map/config-map.js';
import {type K8} from '../integration/kube/k8.js';
import {BlockNodesJsonWrapper} from './block-nodes-json-wrapper.js';
import {K8Helper} from '../business/utils/k8-helper.js';
import {type Container} from '../integration/kube/resources/container/container.js';
import {SemanticVersion} from '../business/utils/semantic-version.js';
export function getInternalAddress(
releaseVersion: SemanticVersion<string> | string,
namespaceName: NamespaceName,
nodeAlias: NodeAlias,
): string {
return new SemanticVersion(releaseVersion).greaterThanOrEqual('0.58.5')
? '127.0.0.1'
: Templates.renderFullyQualifiedNetworkPodName(namespaceName, nodeAlias);
}
export function sleep(duration: Duration): Promise<void> {
return new Promise<void>((resolve: (value: PromiseLike<void> | void) => void): void => {
setTimeout(resolve, duration.toMillis());
});
}
export function parseNodeAliases(
input: string,
consensusNodes?: ConsensusNode[],
configManager?: ConfigManager,
): NodeAliases {
let nodeAliases: NodeAlias[] = splitFlagInput(input, ',') as NodeAliases;
if (nodeAliases.length === 0) {
nodeAliases = consensusNodes?.map((node: {name: string}): NodeAlias => {
return node.name as NodeAlias;
});
configManager?.setFlag(flags.nodeAliasesUnparsed, nodeAliases.join(','));
if (!nodeAliases || nodeAliases.length === 0) {
return [];
}
}
return nodeAliases;
}
export function splitFlagInput(input: string, separator: string = ','): string[] {
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): string => 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>(array: T[]): T[] {
return structuredClone(array);
}
export function getTemporaryDirectory(): string {
return fs.mkdtempSync(PathEx.join(os.tmpdir(), 'solo-'));
}
export function createBackupDirectory(
destinationDirectory: string,
prefix: string = 'backup',
currentDate: Date = new Date(),
): string {
const dateDirectory: string = format(
'%s%s%s_%s%s%s',
currentDate.getFullYear(),
currentDate.getMonth().toString().padStart(2, '0'),
currentDate.getDate().toString().padStart(2, '0'),
currentDate.getHours().toString().padStart(2, '0'),
currentDate.getMinutes().toString().padStart(2, '0'),
currentDate.getSeconds().toString().padStart(2, '0'),
);
const backupDirectory: string = PathEx.join(destinationDirectory, prefix, dateDirectory);
if (!fs.existsSync(backupDirectory)) {
fs.mkdirSync(backupDirectory, {recursive: true});
}
return backupDirectory;
}
export function makeBackup(fileMap: Map<string, string> = new Map<string, string>(), removeOld: boolean = true): void {
for (const entry of fileMap) {
const sourcePath: string = entry[0];
const destinationPath: string = entry[1];
if (fs.existsSync(sourcePath)) {
fs.cpSync(sourcePath, destinationPath);
if (removeOld) {
fs.rmSync(sourcePath);
}
}
}
}
export function backupOldTlsKeys(
nodeAliases: NodeAliases,
keysDirectory: string,
currentDate: Date = new Date(),
directoryPrefix: string = 'tls',
): string {
const backupDirectory: string = createBackupDirectory(keysDirectory, `unused-${directoryPrefix}`, currentDate);
const fileMap: Map<string, string> = new Map<string, string>();
for (const nodeAlias of nodeAliases) {
const sourcePath: string = PathEx.join(keysDirectory, Templates.renderTLSPemPrivateKeyFile(nodeAlias));
const destinationPath: string = PathEx.join(backupDirectory, Templates.renderTLSPemPrivateKeyFile(nodeAlias));
fileMap.set(sourcePath, destinationPath);
}
makeBackup(fileMap, true);
return backupDirectory;
}
export function backupOldPemKeys(
nodeAliases: NodeAliases,
keysDirectory: string,
currentDate: Date = new Date(),
directoryPrefix: string = 'gossip-pem',
): string {
const backupDirectory: string = createBackupDirectory(keysDirectory, `unused-${directoryPrefix}`, currentDate);
const fileMap: Map<string, string> = new Map<string, string>();
for (const nodeAlias of nodeAliases) {
const sourcePath: string = PathEx.join(keysDirectory, Templates.renderGossipPemPrivateKeyFile(nodeAlias));
const destinationPath: string = PathEx.join(backupDirectory, Templates.renderGossipPemPrivateKeyFile(nodeAlias));
fileMap.set(sourcePath, destinationPath);
}
makeBackup(fileMap, true);
return backupDirectory;
}
export function getEnvironmentValue(environmentVariableArray: string[], name: string): string {
const kvPair: string = environmentVariableArray.find((v): boolean => v.startsWith(`${name}=`));
return kvPair ? kvPair.split('=')[1] : undefined;
}
export function parseIpAddressToUint8Array(ipAddress: string): Uint8Array<ArrayBuffer> {
const parts: string[] = ipAddress.split('.');
const uint8Array: Uint8Array<ArrayBuffer> = new Uint8Array(4);
for (let index: number = 0; index < 4; index++) {
uint8Array[index] = Number.parseInt(parts[index], 10);
}
return uint8Array;
}
/** If the basename of the src did not match expected basename, rename it first, then copy to destination */
export function renameAndCopyFile(
sourceFilePath: string,
expectedBaseName: string,
destinationDirectory: string,
): void {
const sourceDirectory: string = path.dirname(sourceFilePath);
if (path.basename(sourceFilePath) !== expectedBaseName) {
fs.renameSync(sourceFilePath, PathEx.join(sourceDirectory, expectedBaseName));
}
// copy public key and private key to key directory
fs.copyFile(
PathEx.joinWithRealPath(sourceDirectory, expectedBaseName),
PathEx.join(destinationDirectory, expectedBaseName),
(error): void => {
if (error) {
throw new SoloError(`Error copying file: ${error.message}`);
}
},
);
}
/**
* Append root.image registry/repository/tag settings for a given node path to a Helm values argument string.
* @param valuesArgument - existing values argument string (may be empty)
* @param nodePath - base node path, e.g. `hedera.nodes[0]`
* @param registry - image registry
* @param repository - image repository
* @param tag - image tag
* @returns updated values argument string
*/
export function addRootImageValues(
valuesArgument: string | undefined,
nodePath: string,
registry: string,
repository: string,
tag: string,
): string {
let updatedValuesArgument: string = valuesArgument ?? '';
updatedValuesArgument += ` --set "${nodePath}.root.image.registry=${registry}"`;
updatedValuesArgument += ` --set "${nodePath}.root.image.tag=${tag}"`;
updatedValuesArgument += ` --set "${nodePath}.root.image.repository=${repository}"`;
return updatedValuesArgument;
}
/**
* 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(context_: AnyListrContext): Record<string, string> {
const exportedContext: Record<string, string> = {} as Record<string, string>;
const config: NodeAddConfigClass = context_.config as NodeAddConfigClass;
const exportedFields: string[] = ['tlsCertHash', 'upgradeZipHash', 'newNode'];
exportedContext.signingCertDer = context_.signingCertDer.toString();
exportedContext.gossipEndpoints = context_.gossipEndpoints.map(
(endpoint: unknown): `${string}:${string}` =>
`${(endpoint as ServiceEndpoint)._domainName}:${(endpoint as ServiceEndpoint)._port}`,
);
exportedContext.grpcServiceEndpoints = context_.grpcServiceEndpoints.map(
(endpoint: unknown): `${string}:${string}` =>
`${(endpoint as ServiceEndpoint)._domainName}:${(endpoint as ServiceEndpoint)._port}`,
);
exportedContext.adminKey = context_.adminKey.toString();
// @ts-expect-error - existingNodeAliases may not be defined on config
exportedContext.existingNodeAliases = config.existingNodeAliases;
for (const property of exportedFields) {
exportedContext[property] = context_[property];
}
return exportedContext;
}
type AddLoadContext = AnyListrContext & {
config: NodeAddConfigClass;
signingCertDer: Uint8Array;
gossipEndpoints: ServiceEndpoint[];
grpcServiceEndpoints: ServiceEndpoint[];
adminKey: PrivateKey;
tlsCertHash: unknown;
upgradeZipHash: unknown;
newNode: unknown;
};
type AddLoadContextData = {
signingCertDer: string;
gossipEndpoints: string[];
grpcServiceEndpoints: string[];
adminKey: string;
newNode: {name: NodeAlias};
existingNodeAliases: NodeAliases;
tlsCertHash: unknown;
upgradeZipHash: unknown;
};
/**
* 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(context_: AddLoadContext, contextData: AddLoadContextData): void {
const config: NodeAddConfigClass = context_.config;
context_.signingCertDer = new Uint8Array(
contextData.signingCertDer.split(',').map((value: string): number => Number.parseInt(value, 10)),
);
context_.gossipEndpoints = prepareEndpoints(
context_.config.endpointType,
contextData.gossipEndpoints,
constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT,
);
context_.grpcServiceEndpoints = prepareEndpoints(
context_.config.endpointType,
contextData.grpcServiceEndpoints,
constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT,
);
context_.adminKey = PrivateKey.fromStringED25519(contextData.adminKey);
config.nodeAlias = contextData.newNode.name;
config.existingNodeAliases = contextData.existingNodeAliases;
config.allNodeAliases = [...config.existingNodeAliases, contextData.newNode.name];
config.newNodeAliases = [contextData.newNode.name];
const fieldsToImport: Array<'tlsCertHash' | 'upgradeZipHash' | 'newNode'> = [
'tlsCertHash',
'upgradeZipHash',
'newNode',
];
for (const property of fieldsToImport) {
context_[property] = contextData[property];
}
}
export function prepareEndpoints(
endpointType: string,
endpoints: string[],
defaultPort: number | string,
): ServiceEndpoint[] {
const returnValue: ServiceEndpoint[] = [];
for (const endpoint of endpoints) {
const parts: string[] = endpoint.split(':');
let url: string = '';
let port: number | string = 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) {
returnValue.push(
new ServiceEndpoint({
port: +port,
ipAddressV4: parseIpAddressToUint8Array(url),
}),
);
} else {
returnValue.push(
new ServiceEndpoint({
port: +port,
domainName: url,
}),
);
}
}
return returnValue;
}
/** Adds all the types of flags as properties on the provided argv object */
export function addFlagsToArgv(
argv: AnyYargs,
flags: {
required: CommandFlag[];
optional: CommandFlag[];
},
): AnyYargs {
argv.required = flags.required;
argv.optional = flags.optional;
return argv;
}
export function resolveValidJsonFilePath(filePath: string, defaultPath?: string): string {
if (!filePath) {
if (defaultPath) {
return resolveValidJsonFilePath(defaultPath);
}
return '';
}
const resolvedFilePath: string = PathEx.realPathSync(filePath);
if (!fs.existsSync(resolvedFilePath)) {
if (defaultPath) {
return resolveValidJsonFilePath(defaultPath);
}
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: Stats = fs.statSync(resolvedFilePath);
if (throttleInfo.size === 0 && defaultPath) {
return resolveValidJsonFilePath(defaultPath);
} 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;
} catch {
// 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);
}
throw new SoloError(`Invalid JSON data in file: ${filePath}`);
}
}
export function prepareValuesFiles(valuesFile: string): string {
let valuesArgument: string = '';
if (valuesFile) {
const valuesFiles: string[] = valuesFile.split(',');
for (const vf of valuesFiles) {
const vfp: string = PathEx.resolve(vf);
valuesArgument += ` --values ${vfp}`;
}
}
return valuesArgument;
}
export function populateHelmArguments(valuesMapping: Record<string, string | boolean | number>): string {
let valuesArgument: string = '';
for (const [key, value] of Object.entries(valuesMapping)) {
valuesArgument += ` --set ${key}=${value}`;
}
return valuesArgument;
}
/**
* @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 === 0) {
return undefined;
}
const consensusNode: ConsensusNode = consensusNodes.find((node): boolean => node.name === nodeAlias);
return consensusNode ? consensusNode.context : undefined;
}
/**
* Check if the namespace exists in the context of given consensus nodes
* @param consensusNodes
* @param k8Factory
* @param namespace
*/
export async function checkNamespace(
consensusNodes: ConsensusNode[],
k8Factory: K8Factory,
namespace: NamespaceName,
): Promise<void> {
for (const consensusNode of consensusNodes) {
const k8: K8 = k8Factory.getK8(consensusNode.context);
if (!(await k8.namespaces().has(namespace))) {
throw new SoloError(`namespace ${namespace} does not exist in context ${consensusNode.context}`);
}
}
}
/**
* Show a banner with the chart name and version
* @param logger
* @param chartName The name of the chart
* @param version The version of the chart
* @param type The action that was performed such as 'Installed' or 'Upgraded'
*/
// TODO convert usages to leverage the logger.addMessageGroupMessage()
export function showVersionBanner(
logger: SoloLogger,
chartName: string,
version: string,
type: 'Installed' | 'Upgraded' = 'Installed',
): void {
logger.showUser(chalk.cyan(` - ${type} ${chartName} chart, version:`, chalk.yellow(version)));
}
/**
* Check if the input is a valid IPv4 address
* @param input
* @returns true if the input is a valid IPv4 address, false otherwise
*/
export function isIpV4Address(input: string): boolean {
const ipv4Regex: RegExp =
/^(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|1?[0-9][0-9]?)$/;
return ipv4Regex.test(input);
}
/**
* Convert an IPv4 address to a base64 string
* @param ipv4 The IPv4 address to convert
* @returns The base64 encoded string representation of the IPv4 address
*/
export function ipV4ToBase64(ipv4: string): string {
// Split the IPv4 address into its octets
const octets: number[] = ipv4.split('.').map((octet): number => {
const number_: number = Number.parseInt(octet, 10);
// eslint-disable-next-line unicorn/prefer-number-properties
if (isNaN(number_) || number_ < 0 || number_ > 255) {
throw new Error(`Invalid IPv4 address: ${ipv4}`);
}
return number_;
});
if (octets.length !== 4) {
throw new Error(`Invalid IPv4 address: ${ipv4}`);
}
// Convert the octets to a Uint8Array
const uint8Array: Uint8Array<ArrayBuffer> = new Uint8Array(octets);
// Base64 encode the byte array
return btoa(String.fromCodePoint(...uint8Array));
}
export function entityId(shard: Shard, realm: Realm, number: Long | number | string): string {
return `${shard}.${realm}.${number}`;
}
export async function withTimeout<T>(
promise: Promise<T>,
duration: Duration,
errorMessage: string = 'Timeout',
): Promise<T> {
return Promise.race([promise, throwAfter(duration, errorMessage)]);
}
async function throwAfter(duration: Duration, message: string = 'Timeout'): Promise<never> {
await sleep(duration);
throw new SoloError(message);
}
/**
* Checks if a Docker image with the given name and tag exists locally.
* @param imageName The name of the Docker image (e.g., "block-node-server").
* @param imageTag The tag of the Docker image (e.g., "0.12.0").
* @returns True if the image exists, false otherwise.
*/
export function checkDockerImageExists(imageName: string, imageTag: string): boolean {
const fullImageName: string = `${imageName}:${imageTag}`;
try {
// Execute the 'docker images' command and filter by the image name
// The --format "{{.Repository}}:{{.Tag}}" ensures consistent output
// We use grep to filter for the exact image:tag
const command: string = `docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^${fullImageName}$"`;
const output: string = execSync(command, {encoding: 'utf8', stdio: 'pipe'});
return output.trim() === fullImageName;
} catch (error: unknown) {
console.error(`Error checking Docker image ${fullImageName}:`, (error as Error).message);
return false;
}
}
export function createDirectoryIfNotExists(file: string): void {
const directory: string = path.dirname(file);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, {recursive: true});
}
}
export async function findMinioOperator(context: string, k8: K8Factory): Promise<ReleaseNameData> {
const minioTenantPod: Optional<Pod> = await k8
.getK8(context)
.pods()
.listForAllNamespaces(['app.kubernetes.io/name=operator', 'operator=leader'])
.then((pods: Pod[]): Optional<Pod> => pods[0]);
if (!minioTenantPod) {
return {
exists: false,
releaseName: undefined,
};
}
return {
exists: true,
releaseName: minioTenantPod.labels?.['app.kubernetes.io/instance'],
};
}
export function remoteConfigsToDeploymentsTable(remoteConfigs: ConfigMap[]): string[] {
const rows: string[] = [];
if (remoteConfigs.length > 0) {
rows.push('Namespace : deployment');
for (const remoteConfig of remoteConfigs) {
const remoteConfigData: unknown = yaml.parse(remoteConfig.data?.['remote-config-data']);
let clustersData: unknown = undefined;
if (typeof remoteConfigData === 'object' && remoteConfigData !== null && 'clusters' in remoteConfigData) {
clustersData = (remoteConfigData as Record<string, unknown>).clusters;
}
const clustersArray: unknown[] = [];
if (Array.isArray(clustersData)) {
clustersArray.push(...clustersData);
} else if (typeof clustersData === 'object' && clustersData !== null) {
clustersArray.push(...Object.values(clustersData));
}
for (const clusterData of clustersArray) {
if (typeof clusterData === 'object' && clusterData !== null && 'deployment' in clusterData) {
const deployment: unknown = (clusterData as Record<string, unknown>).deployment;
if (typeof deployment === 'string') {
rows.push(`${remoteConfig.namespace.name} : ${deployment}`);
}
}
}
}
}
return rows;
}
/**
* Prepare the values files map for each cluster
*
* Order of precedence:
* 1. Chart's default values file (if chartDirectory is set)
* 2. Profile values file
* 3. User's values file
* @param clusterReferences
* @param valuesFileInput - the values file input string
* @param chartDirectory - the chart directory
* @param profileValuesFile - the profile values file full path
*/
export function prepareValuesFilesMap(
clusterReferences: ClusterReferences,
chartDirectory?: string,
profileValuesFile?: string,
valuesFileInput?: string,
): Record<ClusterReferenceName, string> {
// initialize the map with an empty array for each cluster-ref
const valuesFiles: Record<ClusterReferenceName, string> = {
[Flags.KEY_COMMON]: '',
};
for (const [clusterReference] of clusterReferences) {
valuesFiles[clusterReference] = '';
}
// add the chart's default values file for each cluster-ref if chartDirectory is set
// this should be the first in the list of values files as it will be overridden by user's input
if (chartDirectory) {
const chartValuesFile: string = PathEx.join(chartDirectory, 'solo-deployment', 'values.yaml');
for (const clusterReference in valuesFiles) {
valuesFiles[clusterReference] += ` --values ${chartValuesFile}`;
}
}
if (profileValuesFile) {
const parsed: Record<string, Array<string>> = Flags.parseValuesFilesInput(profileValuesFile);
for (const [clusterReference, files] of Object.entries(parsed)) {
let vf: string = '';
for (const file of files) {
vf += ` --values ${file}`;
}
if (clusterReference === Flags.KEY_COMMON) {
for (const [cf] of Object.entries(valuesFiles)) {
valuesFiles[cf] += vf;
}
} else {
valuesFiles[clusterReference] += vf;
}
}
}
if (valuesFileInput) {
const parsed: Record<string, Array<string>> = Flags.parseValuesFilesInput(valuesFileInput);
for (const [clusterReference, files] of Object.entries(parsed)) {
let vf: string = '';
for (const file of files) {
vf += ` --values ${file}`;
}
if (clusterReference === Flags.KEY_COMMON) {
for (const [clusterReference_] of Object.entries(valuesFiles)) {
valuesFiles[clusterReference_] += vf;
}
} else {
valuesFiles[clusterReference] += vf;
}
}
}
if (Object.keys(valuesFiles).length > 1) {
// delete the common key if there is another cluster to use
delete valuesFiles[Flags.KEY_COMMON];
}
return valuesFiles;
}
/**
* Prepare the values files map for each cluster
*
* Order of precedence:
* 1. Chart's default values file (if chartDirectory is set)
* 2. Base values files (applied after chart defaults, before the generated profile values file)
* 3. Profile values file
* 4. User's values file
* @param clusterReferences
* @param chartDirectory - the chart directory
* @param profileValuesFile - mapping of clusterRef to the profile values file full path
* @param valuesFileInput - the values file input string
* @param baseValuesFiles - optional list of values file paths inserted between chart defaults and profile values
*/
export function prepareValuesFilesMapMultipleCluster(
clusterReferences: ClusterReferences,
chartDirectory?: string,
profileValuesFile?: Record<ClusterReferenceName, string>,
valuesFileInput?: string,
baseValuesFiles?: string[],
): Record<ClusterReferenceName, string> {
// initialize the map with an empty array for each cluster-ref
const valuesFiles: Record<ClusterReferenceName, string> = {[Flags.KEY_COMMON]: ''};
for (const [clusterReference] of clusterReferences) {
valuesFiles[clusterReference] = '';
}
// add the chart's default values file for each cluster-ref if chartDirectory is set
// this should be the first in the list of values files as it will be overridden by user's input
if (chartDirectory) {
const chartValuesFile: string = PathEx.join(chartDirectory, 'solo-deployment', 'values.yaml');
for (const clusterReference in valuesFiles) {
valuesFiles[clusterReference] += ` --values ${chartValuesFile}`;
}
}
// add base values files (e.g. component defaults) after chart defaults but before profile values
if (baseValuesFiles) {
for (const file of baseValuesFiles) {
for (const clusterReference in valuesFiles) {
valuesFiles[clusterReference] += ` --values ${file}`;
}
}
}
if (profileValuesFile) {
for (const [clusterReference, file] of Object.entries(profileValuesFile)) {
const valuesArgument: string = ` --values ${file}`;
if (clusterReference === Flags.KEY_COMMON) {
for (const clusterReference_ of Object.keys(valuesFiles)) {
valuesFiles[clusterReference_] += valuesArgument;
}
} else {
valuesFiles[clusterReference] += valuesArgument;
}
}
}
if (valuesFileInput) {
const parsed: Record<string, Array<string>> = Flags.parseValuesFilesInput(valuesFileInput);
for (const [clusterReference, files] of Object.entries(parsed)) {
let vf: string = '';
for (const file of files) {
vf += ` --values ${file}`;
}
if (clusterReference === Flags.KEY_COMMON) {
for (const [clusterReference_] of Object.entries(valuesFiles)) {
valuesFiles[clusterReference_] += vf;
}
} else {
valuesFiles[clusterReference] += vf;
}
}
}
if (Object.keys(valuesFiles).length > 1) {
// delete the common key if there is another cluster to use
delete valuesFiles[Flags.KEY_COMMON];
}
return valuesFiles;
}
/**
* @param consensusNode - the targeted consensus node
* @param logger
* @param k8Factory
*/
export async function createAndCopyBlockNodeJsonFileForConsensusNode(
consensusNode: ConsensusNode,
logger: SoloLogger,
k8Factory: K8Factory,
): Promise<void> {
const {
nodeId,
context,
name: nodeAlias,
blockNodeMap,
externalBlockNodeMap,
namespace: namespaceNameAsString,
} = consensusNode;
const namespace: NamespaceName = NamespaceName.of(namespaceNameAsString);
const blockNodesJsonData: string = new BlockNodesJsonWrapper(blockNodeMap, externalBlockNodeMap).toJSON();
const blockNodesJsonFilename: string = `${constants.BLOCK_NODES_JSON_FILE.replace('.json', '')}-${nodeId}.json`;
const blockNodesJsonPath: string = PathEx.join(constants.SOLO_CACHE_DIR, blockNodesJsonFilename);
fs.writeFileSync(blockNodesJsonPath, JSON.stringify(JSON.parse(blockNodesJsonData), undefined, 2));
// Check if the file exists before copying
if (!fs.existsSync(blockNodesJsonPath)) {
logger.warn(`Block nodes JSON file not found: ${blockNodesJsonPath}`);
return;
}
const k8: K8 = k8Factory.getK8(context);
const container: Container = await new K8Helper(context).getConsensusNodeRootContainer(namespace, nodeAlias);
await container.execContainer('pwd');
const targetDirectory: string = `${constants.HEDERA_HAPI_PATH}/data/config`;
await container.execContainer(`mkdir -p ${targetDirectory}`);
// Copy the file and rename it to block-nodes.json in the destination
await container.copyTo(blockNodesJsonPath, targetDirectory);
// If using node-specific files, rename the copied file to the standard name
const sourceFilename: string = path.basename(blockNodesJsonPath);
await container.execContainer(
`mv ${targetDirectory}/${sourceFilename} ${targetDirectory}/${constants.BLOCK_NODES_JSON_FILE}`,
);
const applicationPropertiesFilePath: string = `${constants.HEDERA_HAPI_PATH}/data/config/${constants.APPLICATION_PROPERTIES}`;
const applicationPropertiesData: string = await container.execContainer(`cat ${applicationPropertiesFilePath}`);
const lines: string[] = applicationPropertiesData.split('\n');
// Remove line to enable overriding below.
for (const line of lines) {
if (line === 'blockStream.streamMode=RECORDS') {
lines.splice(lines.indexOf(line), 1);
}
}
// Switch to block streaming.
if (!lines.some((line): boolean => line.startsWith('blockStream.streamMode='))) {
lines.push(`blockStream.streamMode=${constants.BLOCK_STREAM_STREAM_MODE}`);
}
if (!lines.some((line): boolean => line.startsWith('blockStream.writerMode='))) {
lines.push(`blockStream.writerMode=${constants.BLOCK_STREAM_WRITER_MODE}`);
}
await k8.configMaps().update(namespace, 'network-node-data-config-cm', {
[constants.APPLICATION_PROPERTIES]: lines.join('\n'),
});
const configName: string = `network-${nodeAlias}-data-config-cm`;
const configMapExists: boolean = await k8.configMaps().exists(namespace, configName);
await (configMapExists
? k8.configMaps().update(namespace, configName, {'block-nodes.json': blockNodesJsonData})
: k8.configMaps().create(namespace, configName, {}, {'block-nodes.json': blockNodesJsonData}));
logger.debug(`Copied block-nodes configuration to consensus node ${consensusNode.name}`);
const updatedApplicationPropertiesFilePath: string = PathEx.join(
constants.SOLO_CACHE_DIR,
constants.APPLICATION_PROPERTIES,
);
fs.writeFileSync(updatedApplicationPropertiesFilePath, lines.join('\n'));
await container.copyTo(updatedApplicationPropertiesFilePath, targetDirectory);
}