@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
1,244 lines (1,102 loc) • 78.7 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import {Listr} from 'listr2';
import {ListrInquirerPromptAdapter} from '@listr2/prompt-adapter-inquirer';
import {confirm as confirmPrompt} from '@inquirer/prompts';
import chalk from 'chalk';
import {SoloError} from '../core/errors/solo-error.js';
import {UserBreak} from '../core/errors/user-break.js';
import {BaseCommand} from './base.js';
import {Flags as flags} from './flags.js';
import * as constants from '../core/constants.js';
import {getEnvironmentVariable} from '../core/constants.js';
import {Templates} from '../core/templates.js';
import {
addRootImageValues,
createAndCopyBlockNodeJsonFileForConsensusNode,
parseNodeAliases,
prepareValuesFilesMapMultipleCluster,
resolveValidJsonFilePath,
showVersionBanner,
sleep,
} from '../core/helpers.js';
import {helmValuesHelper} from '../core/helm-values-helper.js';
import {type PerNodeIdentity} from '../types/helm-values.js';
import {resolveNamespaceFromDeployment} from '../core/resolvers.js';
import fs from 'node:fs';
import path from 'node:path';
import {type KeyManager} from '../core/key-manager.js';
import {type PlatformInstaller} from '../core/platform-installer.js';
import {type ProfileManager} from '../core/profile-manager.js';
import {type CertificateManager} from '../core/certificate-manager.js';
import {type AnyListrContext, type ArgvStruct, type IP, type NodeAlias, type NodeAliases} from '../types/aliases.js';
import {ListrLock} from '../core/lock/listr-lock.js';
import {v4 as uuidv4} from 'uuid';
import {
type ClusterReferenceName,
type ClusterReferences,
type ComponentId,
type Context,
type DeploymentName,
type PrivateKeyAndCertificateObject,
type Realm,
type Shard,
type SoloListr,
type SoloListrTask,
type SoloListrTaskWrapper,
} from '../types/index.js';
import {Base64} from 'js-base64';
import {SecretType} from '../integration/kube/resources/secret/secret-type.js';
import {Duration} from '../core/time/duration.js';
import {type Pod} from '../integration/kube/resources/pod/pod.js';
import {PathEx} from '../business/utils/path-ex.js';
import {inject, injectable} from 'tsyringe-neo';
import {InjectTokens} from '../core/dependency-injection/inject-tokens.js';
import {patchInject} from '../core/dependency-injection/container-helper.js';
import {type CommandFlag, type CommandFlags} from '../types/flag-types.js';
import {type K8} from '../integration/kube/k8.js';
import {type Lock} from '../core/lock/lock.js';
import {type LoadBalancerIngress} from '../integration/kube/resources/load-balancer-ingress.js';
import {type Service} from '../integration/kube/resources/service/service.js';
import {type Container} from '../integration/kube/resources/container/container.js';
import {DeploymentPhase} from '../data/schema/model/remote/deployment-phase.js';
import {ComponentTypes} from '../core/config/remote/enumerations/component-types.js';
import {PvcName} from '../integration/kube/resources/pvc/pvc-name.js';
import {PvcReference} from '../integration/kube/resources/pvc/pvc-reference.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {ConsensusNode} from '../core/model/consensus-node.js';
import {BlockNodeStateSchema} from '../data/schema/model/remote/state/block-node-state-schema.js';
import {SemanticVersion} from '../business/utils/semantic-version.js';
import {Secret} from '../integration/kube/resources/secret/secret.js';
import * as versions from '../../version.js';
import {K8Helper} from '../business/utils/k8-helper.js';
import {PackageDownloader} from '../core/package-downloader.js';
import {Zippy} from '../core/zippy.js';
import {type SoloEventBus} from '../core/events/solo-event-bus.js';
import {NetworkDeployedEvent} from '../core/events/event-types/network-deployed-event.js';
import {type Wraps} from '../business/runtime-state/config/solo/wraps.js';
export interface NetworkDeployConfigClass {
isUpgrade: boolean;
applicationEnv: string;
chainId: string;
cacheDir: string;
chartDirectory: string;
loadBalancerEnabled: boolean;
soloChartVersion: string;
namespace: NamespaceName;
deployment: string;
nodeAliasesUnparsed: string;
persistentVolumeClaims: string;
releaseTag: string;
keysDir: string;
nodeAliases: NodeAliases;
stagingDir: string;
stagingKeysDir: string;
valuesFile: string;
valuesArgMap: Record<ClusterReferenceName, string>;
grpcTlsCertificatePath: string;
grpcWebTlsCertificatePath: string;
grpcTlsKeyPath: string;
grpcWebTlsKeyPath: string;
genesisThrottlesFile: string;
resolvedThrottlesFile: string;
haproxyIps: string;
envoyIps: string;
haproxyIpsParsed?: Record<NodeAlias, IP>;
envoyIpsParsed?: Record<NodeAlias, IP>;
storageType: constants.StorageType;
gcsWriteAccessKey: string;
gcsWriteSecrets: string;
gcsEndpoint: string;
gcsBucket: string;
gcsBucketPrefix: string;
awsWriteAccessKey: string;
awsWriteSecrets: string;
awsEndpoint: string;
awsBucket: string;
awsBucketPrefix: string;
awsBucketRegion: string;
backupBucket: string;
backupWriteSecrets: string;
backupWriteAccessKey: string;
backupEndpoint: string;
backupRegion: string;
backupProvider: string;
consensusNodes: ConsensusNode[];
contexts: string[];
clusterRefs: ClusterReferences;
domainNames?: string;
domainNamesMapping?: Record<NodeAlias, string>;
blockNodeComponents: BlockNodeStateSchema[];
debugNodeAlias: NodeAlias;
app: string;
serviceMonitor: string;
podLog: string;
singleUseServiceMonitor: string;
singleUsePodLog: string;
enableMonitoringSupport: boolean;
javaFlightRecorderConfiguration: string;
wrapsEnabled: boolean;
wrapsKeyPath: string;
tssEnabled: boolean;
}
interface NetworkDeployContext {
config: NetworkDeployConfigClass;
}
export interface NetworkDestroyContext {
config: {
deletePvcs: boolean;
deleteSecrets: boolean;
namespace: NamespaceName;
enableTimeout: boolean;
force: boolean;
contexts: string[];
deployment: string;
};
checkTimeout: boolean;
}
@injectable()
export class NetworkCommand extends BaseCommand {
private profileValuesFile?: Record<ClusterReferenceName, string>;
public constructor(
@inject(InjectTokens.CertificateManager) private readonly certificateManager: CertificateManager,
@inject(InjectTokens.KeyManager) private readonly keyManager: KeyManager,
@inject(InjectTokens.PlatformInstaller) private readonly platformInstaller: PlatformInstaller,
@inject(InjectTokens.ProfileManager) private readonly profileManager: ProfileManager,
@inject(InjectTokens.Zippy) private readonly zippy: Zippy,
@inject(InjectTokens.PackageDownloader) private readonly downloader: PackageDownloader,
@inject(InjectTokens.SoloEventBus) private readonly eventBus: SoloEventBus,
) {
super();
this.certificateManager = patchInject(certificateManager, InjectTokens.CertificateManager, this.constructor.name);
this.keyManager = patchInject(keyManager, InjectTokens.KeyManager, this.constructor.name);
this.platformInstaller = patchInject(platformInstaller, InjectTokens.PlatformInstaller, this.constructor.name);
this.profileManager = patchInject(profileManager, InjectTokens.ProfileManager, this.constructor.name);
this.zippy = patchInject(zippy, InjectTokens.Zippy, this.constructor.name);
this.downloader = patchInject(downloader, InjectTokens.PackageDownloader, this.constructor.name);
}
private static readonly DEPLOY_CONFIGS_NAME: string = 'deployConfigs';
public static readonly DESTROY_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [flags.deletePvcs, flags.deleteSecrets, flags.enableTimeout, flags.force, flags.quiet],
};
public static readonly DEPLOY_FLAGS_LIST: CommandFlags = {
required: [flags.deployment],
optional: [
flags.apiPermissionProperties,
flags.app,
flags.applicationEnv,
flags.applicationProperties,
flags.bootstrapProperties,
flags.genesisThrottlesFile,
flags.cacheDir,
flags.chainId,
flags.chartDirectory,
flags.soloChartVersion,
flags.debugNodeAlias,
flags.loadBalancerEnabled,
flags.log4j2Xml,
flags.persistentVolumeClaims,
flags.quiet,
flags.releaseTag,
flags.settingTxt,
flags.networkDeploymentValuesFile,
flags.nodeAliasesUnparsed,
flags.grpcTlsCertificatePath,
flags.grpcWebTlsCertificatePath,
flags.grpcTlsKeyPath,
flags.grpcWebTlsKeyPath,
flags.haproxyIps,
flags.envoyIps,
flags.storageType,
flags.gcsWriteAccessKey,
flags.gcsWriteSecrets,
flags.gcsEndpoint,
flags.gcsBucket,
flags.gcsBucketPrefix,
flags.awsWriteAccessKey,
flags.awsWriteSecrets,
flags.awsEndpoint,
flags.awsBucket,
flags.awsBucketRegion,
flags.awsBucketPrefix,
flags.backupBucket,
flags.backupWriteAccessKey,
flags.backupWriteSecrets,
flags.backupEndpoint,
flags.backupRegion,
flags.backupProvider,
flags.domainNames,
flags.serviceMonitor,
flags.podLog,
flags.enableMonitoringSupport,
flags.javaFlightRecorderConfiguration,
flags.wrapsEnabled,
flags.wrapsKeyPath,
flags.tssEnabled,
],
};
private waitForNetworkPods(): SoloListrTask<NetworkDeployContext> {
return {
title: 'Check node pods are running',
task: (context_, task): SoloListr<NetworkDeployContext> => {
const subTasks: SoloListrTask<NetworkDeployContext>[] = [];
const config: NetworkDeployConfigClass = context_.config;
for (const consensusNode of config.consensusNodes) {
subTasks.push({
title: `Check Node: ${chalk.yellow(consensusNode.name)}, Cluster: ${chalk.yellow(consensusNode.cluster)}`,
task: async (): Promise<void> => {
await this.k8Factory
.getK8(consensusNode.context)
.pods()
.waitForRunningPhase(
config.namespace,
[`solo.hedera.com/node-name=${consensusNode.name}`, 'solo.hedera.com/type=network-node'],
constants.PODS_RUNNING_MAX_ATTEMPTS,
constants.PODS_RUNNING_DELAY,
);
},
});
}
// set up the sub-tasks
return task.newListr(subTasks, {
concurrent: true,
rendererOptions: {
collapseSubtasks: false,
},
});
},
};
}
private async prepareMinioSecrets(
config: NetworkDeployConfigClass,
minioAccessKey: string,
minioSecretKey: string,
): Promise<void> {
// Generating new minio credentials
const minioData: Record<string, string> = {};
const namespace: NamespaceName = config.namespace;
const environmentString: string = `MINIO_ROOT_USER=${minioAccessKey}\nMINIO_ROOT_PASSWORD=${minioSecretKey}`;
minioData['config.env'] = Base64.encode(environmentString);
// create minio secret in each cluster
for (const context of config.contexts) {
this.logger.debug(`creating minio secret using context: ${context}`);
const isMinioSecretCreated: boolean = await this.k8Factory
.getK8(context)
.secrets()
.createOrReplace(namespace, constants.MINIO_SECRET_NAME, SecretType.OPAQUE, minioData);
if (!isMinioSecretCreated) {
throw new SoloError(`failed to create new minio secret using context: ${context}`);
}
this.logger.debug(`created minio secret using context: ${context}`);
}
}
private async prepareStreamUploaderSecrets(config: NetworkDeployConfigClass): Promise<void> {
const namespace: NamespaceName = config.namespace;
// Generating cloud storage secrets
const {gcsWriteAccessKey, gcsWriteSecrets, gcsEndpoint, awsWriteAccessKey, awsWriteSecrets, awsEndpoint} = config;
const cloudData: Record<string, string> = {};
if (
config.storageType === constants.StorageType.AWS_ONLY ||
config.storageType === constants.StorageType.AWS_AND_GCS
) {
cloudData['S3_ACCESS_KEY'] = Base64.encode(awsWriteAccessKey);
cloudData['S3_SECRET_KEY'] = Base64.encode(awsWriteSecrets);
cloudData['S3_ENDPOINT'] = Base64.encode(awsEndpoint);
}
if (
config.storageType === constants.StorageType.GCS_ONLY ||
config.storageType === constants.StorageType.AWS_AND_GCS
) {
cloudData['GCS_ACCESS_KEY'] = Base64.encode(gcsWriteAccessKey);
cloudData['GCS_SECRET_KEY'] = Base64.encode(gcsWriteSecrets);
cloudData['GCS_ENDPOINT'] = Base64.encode(gcsEndpoint);
}
// create secret in each cluster
for (const context of config.contexts) {
this.logger.debug(
`creating secret for storage credential of type '${config.storageType}' using context: ${context}`,
);
const isCloudSecretCreated: boolean = await this.k8Factory
.getK8(context)
.secrets()
.createOrReplace(namespace, constants.UPLOADER_SECRET_NAME, SecretType.OPAQUE, cloudData);
if (!isCloudSecretCreated) {
throw new SoloError(
`failed to create secret for storage credentials of type '${config.storageType}' using context: ${context}`,
);
}
this.logger.debug(
`created secret for storage credential of type '${config.storageType}' using context: ${context}`,
);
}
}
private async prepareBackupUploaderSecrets(config: NetworkDeployConfigClass): Promise<void> {
const {backupWriteAccessKey, backupWriteSecrets, backupEndpoint, backupRegion, backupProvider} = config;
const backupData: Record<string, string> = {};
const namespace: NamespaceName = config.namespace;
backupData['AWS_ACCESS_KEY_ID'] = Base64.encode(backupWriteAccessKey);
backupData['AWS_SECRET_ACCESS_KEY'] = Base64.encode(backupWriteSecrets);
backupData['RCLONE_CONFIG_BACKUPS_ENDPOINT'] = Base64.encode(backupEndpoint);
backupData['RCLONE_CONFIG_BACKUPS_REGION'] = Base64.encode(backupRegion);
backupData['RCLONE_CONFIG_BACKUPS_TYPE'] = Base64.encode('s3');
backupData['RCLONE_CONFIG_BACKUPS_PROVIDER'] = Base64.encode(backupProvider);
// create secret in each cluster
for (const context of config.contexts) {
this.logger.debug(`creating secret for backup uploader using context: ${context}`);
const k8client: K8 = this.k8Factory.getK8(context);
const isBackupSecretCreated: boolean = await k8client
.secrets()
.createOrReplace(namespace, constants.BACKUP_SECRET_NAME, SecretType.OPAQUE, backupData);
if (!isBackupSecretCreated) {
throw new SoloError(`failed to create secret for backup uploader using context: ${context}`);
}
this.logger.debug(`created secret for backup uploader using context: ${context}`);
}
}
private async prepareStorageSecrets(config: NetworkDeployConfigClass): Promise<void> {
try {
if (config.storageType !== constants.StorageType.MINIO_ONLY) {
const minioAccessKey: string = uuidv4();
const minioSecretKey: string = uuidv4();
await this.prepareMinioSecrets(config, minioAccessKey, minioSecretKey);
await this.prepareStreamUploaderSecrets(config);
}
if (config.backupBucket) {
await this.prepareBackupUploaderSecrets(config);
}
} catch (error) {
throw new SoloError('Failed to create Kubernetes storage secret', error);
}
}
/**
* Prepare values args string for each cluster-ref
* @param config
*/
private async prepareValuesArgMap(config: NetworkDeployConfigClass): Promise<Record<ClusterReferenceName, string>> {
const valuesArguments: Record<ClusterReferenceName, string> = this.prepareValuesArg(config);
// prepare values files for each cluster
const valuesArgumentMap: Record<ClusterReferenceName, string> = {};
const deploymentName: DeploymentName = this.configManager.getFlag(flags.deployment);
const applicationPropertiesPath: string = PathEx.joinWithRealPath(
config.cacheDir,
'templates',
constants.APPLICATION_PROPERTIES,
);
const jfrFilePath: string = config.javaFlightRecorderConfiguration;
const jfrFile: string =
jfrFilePath === '' ? '' : jfrFilePath.slice(Math.max(0, jfrFilePath.lastIndexOf(path.sep) + 1));
this.profileValuesFile = await this.profileManager.prepareValuesForSoloChart(
config.consensusNodes,
deploymentName,
applicationPropertiesPath,
jfrFile,
{
// Pass command-scoped values explicitly so profile/staging generation is isolated
// from mutable global flags when one-shot runs parallel subcommands.
cacheDir: config.cacheDir,
releaseTag: config.releaseTag,
appName: config.app,
chainId: config.chainId,
},
);
const valuesFiles: Record<ClusterReferenceName, string> = prepareValuesFilesMapMultipleCluster(
config.clusterRefs,
config.chartDirectory,
this.profileValuesFile,
config.valuesFile,
[constants.SOLO_DEPLOYMENT_VALUES_FILE],
);
// Generate per-cluster extraEnv values files to avoid passing the global node list to every
// cluster's Helm upgrade (in multi-cluster deployments each cluster has its own node subset).
// Each file carries only the nodes that belong to the target cluster, preventing Helm's
// array-replacement semantics from inserting nodes from other clusters.
const perClusterExtraEnvironmentValuesFiles: Record<ClusterReferenceName, string> = {};
const needsExtraEnvironment: boolean =
config.wrapsEnabled || !!config.debugNodeAlias || config.app !== constants.HEDERA_APP_NAME; // JAVA_MAIN_CLASS for tools/local builds
if (needsExtraEnvironment) {
const realm: Realm = this.localConfig.configuration.realmForDeployment(config.deployment);
const shard: Shard = this.localConfig.configuration.shardForDeployment(config.deployment);
for (const clusterReference of Object.keys(valuesFiles)) {
// Only include nodes belonging to this cluster so the generated hedera.nodes array
// matches the cluster-specific node set and does not overwrite nodes in other clusters.
// Sort deterministically by nodeId so per-node Helm values align with the chart's
// expected node ordering regardless of upstream object iteration order.
const clusterConsensusNodes: ConsensusNode[] = config.consensusNodes
.filter((node): boolean => node.cluster === clusterReference)
// eslint-disable-next-line unicorn/no-array-sort
.sort((left, right): number => left.nodeId - right.nodeId);
if (clusterConsensusNodes.length === 0) {
continue;
}
const additionalNodeValues: Record<
NodeAlias,
{name: NodeAlias; nodeId: number; accountId: string; blockNodesJson?: string}
> = {};
// Preserve blockNodesJson from the per-cluster profile values file so that it is not
// silently dropped when the extraEnv values file replaces the hedera.nodes array.
const clusterProfileValuesFile: string | undefined = this.profileValuesFile?.[clusterReference];
const nodeIdentityMap: Record<NodeAlias, PerNodeIdentity> = clusterProfileValuesFile
? helmValuesHelper.extractPerNodeIdentityFromValuesFile(clusterProfileValuesFile, clusterConsensusNodes)
: {};
const blockNodesJsonMap: Record<NodeAlias, string> = clusterProfileValuesFile
? helmValuesHelper.extractPerNodeBlockNodesJsonFromValuesFile(clusterProfileValuesFile, clusterConsensusNodes)
: {};
for (const consensusNode of clusterConsensusNodes) {
const identity: PerNodeIdentity = nodeIdentityMap[consensusNode.name] ?? {};
additionalNodeValues[consensusNode.name] = {
name: identity.name ?? consensusNode.name,
nodeId: identity.nodeId ?? consensusNode.nodeId,
// Prefer the accountId recorded in the profile values file (set by the account
// manager using the deployment's configured start account ID) over the computed
// default, so custom account IDs assigned via node transactions are preserved.
accountId:
identity.accountId ?? `${shard}.${realm}.${constants.DEFAULT_START_ID_NUMBER + consensusNode.nodeId}`,
};
if (blockNodesJsonMap[consensusNode.name]) {
additionalNodeValues[consensusNode.name].blockNodesJson = blockNodesJsonMap[consensusNode.name];
}
}
// Collect extraEnv entries already present in this cluster's values files so that the
// generated file can include them and avoid Helm array replacement silently dropping
// env vars set by user-provided values files.
const existingValuesFilePaths: string[] = helmValuesHelper.parseValuesFilePaths(valuesFiles[clusterReference]);
const clusterExtraEnvironmentValuesFile: string = helmValuesHelper.generateExtraEnvironmentValuesFile(
clusterConsensusNodes,
{
wrapsEnabled: config.wrapsEnabled,
tss: this.soloConfig.tss,
debugNodeAlias: config.debugNodeAlias,
useJavaMainClass: config.app !== constants.HEDERA_APP_NAME,
additionalNodeValues,
baseExtraEnvironmentVariables: helmValuesHelper.extractExtraEnvironmentFromValuesFiles(
existingValuesFilePaths,
clusterConsensusNodes,
),
},
config.cacheDir,
);
perClusterExtraEnvironmentValuesFiles[clusterReference] = clusterExtraEnvironmentValuesFile;
this.logger.debug(
`Created per-cluster extraEnv values file for ${clusterReference}: ${clusterExtraEnvironmentValuesFile}`,
);
}
}
for (const clusterReference of Object.keys(valuesFiles)) {
// Keep --set flags last so they override values files. This is critical when we also
// provide per-node extraEnv via a values file (e.g. --debug-node-alias), because a later
// values file can replace array elements and drop fields like node labels/account IDs.
let valuesArgument: string = valuesFiles[clusterReference];
// Add per-cluster extraEnv values file if any extraEnv customizations are needed
if (perClusterExtraEnvironmentValuesFiles[clusterReference]) {
valuesArgument += ` --values "${perClusterExtraEnvironmentValuesFiles[clusterReference]}"`;
}
valuesArgument += valuesArguments[clusterReference];
valuesArgumentMap[clusterReference] = valuesArgument;
this.logger.debug(`Prepared helm chart values for cluster-ref: ${clusterReference}`, {
valuesArgument: valuesArgumentMap[clusterReference],
});
}
return valuesArgumentMap;
}
/**
* Prepare the values argument for the helm chart for a given config
* @param config
*/
private prepareValuesArg(config: NetworkDeployConfigClass): Record<ClusterReferenceName, string> {
const valuesArguments: Record<ClusterReferenceName, string> = {};
const clusterReferences: ClusterReferenceName[] = [];
// initialize the valueArgs
for (const consensusNode of config.consensusNodes) {
// add the cluster to the list of clusters
if (!clusterReferences.includes(consensusNode.cluster)) {
clusterReferences.push(consensusNode.cluster);
}
// Initialize empty valuesArg for each cluster
// All extraEnv logic (JAVA_MAIN_CLASS, TSS wraps, debug) is now handled via values files
if (!valuesArguments[consensusNode.cluster]) {
valuesArguments[consensusNode.cluster] = '';
}
}
// All extraEnv customizations (wraps, debug, JAVA_MAIN_CLASS) are handled
// via generateExtraEnvironmentValuesFile() in prepareValuesArgMap() to avoid Helm --set replacement issues
if (
config.storageType === constants.StorageType.AWS_AND_GCS ||
config.storageType === constants.StorageType.GCS_ONLY
) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ' --set cloud.gcs.enabled=true';
}
}
if (
config.storageType === constants.StorageType.AWS_AND_GCS ||
config.storageType === constants.StorageType.AWS_ONLY
) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ' --set cloud.s3.enabled=true';
}
}
if (
config.storageType === constants.StorageType.GCS_ONLY ||
config.storageType === constants.StorageType.AWS_ONLY ||
config.storageType === constants.StorageType.AWS_AND_GCS
) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ' --set cloud.minio.enabled=false';
}
}
if (config.storageType !== constants.StorageType.MINIO_ONLY) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ' --set cloud.generateNewSecrets=false';
}
}
if (config.gcsBucket) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] +=
` --set cloud.buckets.streamBucket=${config.gcsBucket}` +
` --set minio-server.tenant.buckets[0].name=${config.gcsBucket}`;
}
}
if (config.gcsBucketPrefix) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ` --set cloud.buckets.streamBucketPrefix=${config.gcsBucketPrefix}`;
}
}
if (config.awsBucket) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] +=
` --set cloud.buckets.streamBucket=${config.awsBucket}` +
` --set minio-server.tenant.buckets[0].name=${config.awsBucket}`;
}
}
if (config.awsBucketPrefix) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ` --set cloud.buckets.streamBucketPrefix=${config.awsBucketPrefix}`;
}
}
if (config.awsBucketRegion) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ` --set cloud.buckets.streamBucketRegion=${config.awsBucketRegion}`;
}
}
if (config.backupBucket) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] +=
' --set defaults.sidecars.backupUploader.enabled=true' +
` --set defaults.sidecars.backupUploader.config.backupBucket=${config.backupBucket}`;
}
}
const nodeIndexByClusterAndName: Map<string, number> = new Map();
const nextNodeIndexByCluster: Map<ClusterReferenceName, number> = new Map();
for (const consensusNode of config.consensusNodes) {
const nodeIndex: number = nextNodeIndexByCluster.get(consensusNode.cluster) ?? 0;
nextNodeIndexByCluster.set(consensusNode.cluster, nodeIndex + 1);
nodeIndexByClusterAndName.set(`${consensusNode.cluster}:${consensusNode.name}`, nodeIndex);
}
for (const consensusNode of config.consensusNodes) {
const nodeIndex: number | undefined = nodeIndexByClusterAndName.get(
`${consensusNode.cluster}:${consensusNode.name}`,
);
if (nodeIndex === undefined) {
continue;
}
let valuesArgument: string = valuesArguments[consensusNode.cluster] ?? '';
valuesArgument += ` --set "hedera.nodes[${nodeIndex}].name=${consensusNode.name}"`;
valuesArgument = addRootImageValues(
valuesArgument,
`hedera.nodes[${nodeIndex}]`,
constants.S6_NODE_IMAGE_REGISTRY,
constants.S6_NODE_IMAGE_REPOSITORY,
versions.S6_NODE_IMAGE_VERSION,
);
valuesArguments[consensusNode.cluster] = valuesArgument;
}
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] +=
' --install' +
' --set "telemetry.prometheus.svcMonitor.enabled=false"' + // remove after chart version is bumped
` --set "crds.serviceMonitor.enabled=${config.singleUseServiceMonitor}"` +
` --set "crds.podLog.enabled=${config.singleUsePodLog}"` +
` --set "defaults.volumeClaims.enabled=${config.persistentVolumeClaims}"`;
}
config.singleUseServiceMonitor = 'false';
config.singleUsePodLog = 'false';
// Iterate over each node and set static IPs for HAProxy
this.addArgForEachRecord(
config.haproxyIpsParsed,
config.consensusNodes,
valuesArguments,
' --set "hedera.nodes[${nodeId}].haproxyStaticIP=${recordValue}"',
);
// Iterate over each node and set static IPs for Envoy Proxy
this.addArgForEachRecord(
config.envoyIpsParsed,
config.consensusNodes,
valuesArguments,
' --set "hedera.nodes[${nodeId}].envoyProxyStaticIP=${recordValue}"',
);
if (config.resolvedThrottlesFile) {
// repairing the path, this avoid helm failing when running on windows
const throttlesFilePath: string = config.resolvedThrottlesFile.replaceAll('\\', '/');
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] +=
` --set-file "hedera.configMaps.genesisThrottlesJson=${throttlesFilePath}"`;
}
}
if (config.loadBalancerEnabled) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] +=
' --set "defaults.haproxy.service.type=LoadBalancer"' +
' --set "defaults.envoyProxy.service.type=LoadBalancer"' +
' --set "defaults.consensus.service.type=LoadBalancer"';
}
}
if (config.enableMonitoringSupport) {
for (const clusterReference of clusterReferences) {
valuesArguments[clusterReference] += ' --set "crs.podLog.enabled=true" --set "crs.serviceMonitor.enabled=true"';
}
}
return valuesArguments;
}
/**
* Adds the template string to the argument for each record
* @param records - the records to iterate over
* @param consensusNodes - the consensus nodes to iterate over
* @param valuesArguments - the values arguments to add to
* @param templateString - the template string to add
*/
private addArgForEachRecord(
records: Record<NodeAlias, string>,
consensusNodes: ConsensusNode[],
valuesArguments: Record<ClusterReferenceName, string>,
templateString: string,
): void {
if (records) {
for (const consensusNode of consensusNodes) {
if (records[consensusNode.name]) {
const newTemplateString: string = templateString.replace('{nodeId}', consensusNode.nodeId.toString());
valuesArguments[consensusNode.cluster] += newTemplateString.replace(
'{recordValue}',
records[consensusNode.name],
);
}
}
}
}
private async prepareNamespaces(config: NetworkDeployConfigClass): Promise<void> {
const namespace: NamespaceName = config.namespace;
// check and create namespace in each cluster
for (const context of config.contexts) {
const k8client: K8 = this.k8Factory.getK8(context);
if (await k8client.namespaces().has(namespace)) {
this.logger.debug(`namespace '${namespace}' found using context: ${context}`);
} else {
this.logger.debug(`creating namespace '${namespace}' using context: ${context}`);
await k8client.namespaces().create(namespace);
this.logger.debug(`created namespace '${namespace}' using context: ${context}`);
}
}
}
private async prepareConfig(
task: SoloListrTaskWrapper<NetworkDeployContext>,
argv: ArgvStruct,
): Promise<NetworkDeployConfigClass> {
const flagsWithDisabledPrompts: CommandFlag[] = [
flags.apiPermissionProperties,
flags.app,
flags.applicationEnv,
flags.applicationProperties,
flags.bootstrapProperties,
flags.genesisThrottlesFile,
flags.cacheDir,
flags.chainId,
flags.chartDirectory,
flags.debugNodeAlias,
flags.loadBalancerEnabled,
flags.log4j2Xml,
flags.persistentVolumeClaims,
flags.settingTxt,
flags.grpcTlsCertificatePath,
flags.grpcWebTlsCertificatePath,
flags.grpcTlsKeyPath,
flags.grpcWebTlsKeyPath,
flags.haproxyIps,
flags.envoyIps,
flags.storageType,
flags.gcsWriteAccessKey,
flags.gcsWriteSecrets,
flags.gcsEndpoint,
flags.gcsBucket,
flags.gcsBucketPrefix,
flags.nodeAliasesUnparsed,
flags.domainNames,
];
// disable the prompts that we don't want to prompt the user for
flags.disablePrompts(flagsWithDisabledPrompts);
const allFlags: CommandFlag[] = [
...NetworkCommand.DEPLOY_FLAGS_LIST.optional,
...NetworkCommand.DEPLOY_FLAGS_LIST.required,
];
await this.configManager.executePrompt(task, allFlags);
const namespace: NamespaceName =
(await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task)) ??
NamespaceName.of(this.configManager.getFlag(flags.deployment));
this.configManager.setFlag(flags.namespace, namespace);
// create a config object for subsequent steps
const config: NetworkDeployConfigClass = this.configManager.getConfig(
NetworkCommand.DEPLOY_CONFIGS_NAME,
allFlags,
[
'keysDir',
'nodeAliases',
'stagingDir',
'stagingKeysDir',
'valuesArgMap',
'resolvedThrottlesFile',
'namespace',
'consensusNodes',
'contexts',
'clusterRefs',
'singleUsePodLog',
'singleUseServiceMonitor',
],
) as NetworkDeployConfigClass;
const realm: Realm = this.localConfig.configuration.realmForDeployment(config.deployment);
const shard: Shard = this.localConfig.configuration.shardForDeployment(config.deployment);
const networkNodeVersion: SemanticVersion<string> = new SemanticVersion<string>(config.releaseTag);
const minimumVersionForNonZeroRealms: SemanticVersion<string> = new SemanticVersion<string>('0.60.0');
if (
(realm !== 0 || shard !== 0) &&
new SemanticVersion<string>(networkNodeVersion).lessThan(minimumVersionForNonZeroRealms)
) {
throw new SoloError(
`The realm and shard values must be 0 when using the ${minimumVersionForNonZeroRealms} version of the network node`,
);
}
if (config.haproxyIps) {
config.haproxyIpsParsed = Templates.parseNodeAliasToIpMapping(config.haproxyIps);
}
if (config.envoyIps) {
config.envoyIpsParsed = Templates.parseNodeAliasToIpMapping(config.envoyIps);
}
if (config.domainNames) {
config.domainNamesMapping = Templates.parseNodeAliasToDomainNameMapping(config.domainNames);
}
// compute other config parameters
config.keysDir = PathEx.join(config.cacheDir, 'keys');
config.stagingDir = Templates.renderStagingDir(config.cacheDir, config.releaseTag);
config.stagingKeysDir = PathEx.join(config.stagingDir, 'keys');
config.resolvedThrottlesFile = resolveValidJsonFilePath(
config.genesisThrottlesFile,
flags.genesisThrottlesFile.definition.defaultValue as string,
);
config.consensusNodes = this.remoteConfig.getConsensusNodes();
config.contexts = this.remoteConfig.getContexts();
config.clusterRefs = this.remoteConfig.getClusterRefs();
config.nodeAliases = parseNodeAliases(config.nodeAliasesUnparsed, config.consensusNodes, this.configManager);
argv[flags.nodeAliasesUnparsed.name] = config.nodeAliases.join(',');
config.blockNodeComponents = this.getBlockNodes();
config.javaFlightRecorderConfiguration = this.configManager.getFlag(flags.javaFlightRecorderConfiguration);
if (config.javaFlightRecorderConfiguration === '') {
config.javaFlightRecorderConfiguration = getEnvironmentVariable('JAVA_FLIGHT_RECORDER_CONFIGURATION') || '';
}
config.singleUseServiceMonitor = config.serviceMonitor;
config.singleUsePodLog = config.podLog;
config.valuesArgMap = await this.prepareValuesArgMap(config);
// need to prepare the namespaces before we can proceed
config.namespace = namespace;
await this.prepareNamespaces(config);
// prepare staging keys directory
if (!fs.existsSync(config.stagingKeysDir)) {
fs.mkdirSync(config.stagingKeysDir, {recursive: true});
}
// create cached keys dir if it does not exist yet
if (!fs.existsSync(config.keysDir)) {
fs.mkdirSync(config.keysDir);
}
this.logger.debug('Preparing storage secrets');
await this.prepareStorageSecrets(config);
return config;
}
private async destroyTask(
task: SoloListrTaskWrapper<NetworkDestroyContext>,
namespace: NamespaceName,
deletePvcs: boolean,
deleteSecrets: boolean,
contexts: Context[],
): Promise<void> {
task.title = `Uninstalling chart ${constants.SOLO_DEPLOYMENT_CHART}`;
// Uninstall all 'solo deployment' charts for each cluster using the contexts
await this.logDestroyResults(
'Uninstall solo-deployment chart',
await Promise.allSettled(
contexts.map(async (context): Promise<void> => {
await this.chartManager.uninstall(
namespace,
constants.SOLO_DEPLOYMENT_CHART,
this.k8Factory.getK8(context).contexts().readCurrent(),
);
}),
),
);
task.title = `Deleting the RemoteConfig configmap in namespace ${namespace}`;
await this.logDestroyResults(
'Delete remote config configmap',
await Promise.allSettled(
contexts.map(async (context): Promise<void> => {
await this.k8Factory.getK8(context).configMaps().delete(namespace, constants.SOLO_REMOTE_CONFIGMAP_NAME);
}),
),
);
if (deletePvcs) {
task.title = `Deleting PVCs in namespace ${namespace}`;
await this.logDestroyResults('Delete PVCs', await Promise.allSettled([this.deletePvcs(namespace, contexts)]));
}
if (deleteSecrets) {
task.title = `Deleting Secrets in namespace ${namespace}`;
await this.logDestroyResults(
'Delete secrets',
await Promise.allSettled([this.deleteSecrets(namespace, contexts)]),
);
}
if (deleteSecrets && deletePvcs) {
task.title = `Deleting namespace ${namespace}`;
await this.logDestroyResults(
'Delete namespace',
await Promise.allSettled(
contexts.map(async (context): Promise<void> => {
await this.k8Factory.getK8(context).namespaces().delete(namespace);
}),
),
);
} else {
task.title = `Deleting the RemoteConfig configmap in namespace ${namespace}`;
await Promise.all(
contexts.map(async (context): Promise<void> => {
await this.k8Factory.getK8(context).configMaps().delete(namespace, constants.SOLO_REMOTE_CONFIGMAP_NAME);
}),
);
if (deletePvcs) {
task.title = `Deleting PVCs in namespace ${namespace}`;
await this.deletePvcs(namespace, contexts);
}
if (deleteSecrets) {
task.title = `Deleting Secrets in namespace ${namespace}`;
await this.deleteSecrets(namespace, contexts);
}
}
}
private async logDestroyResults(title: string, results: PromiseSettledResult<void>[]): Promise<void> {
const failures: PromiseRejectedResult[] = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (failures.length === 0) {
return;
}
for (const failure of failures) {
this.logger.warn(`${title} failed; continuing destroy`, failure.reason);
}
}
private async deleteSecrets(namespace: NamespaceName, contexts: Context[]): Promise<void> {
const secretsData: Array<{secret: string; context: Context}> = [];
for (const context of contexts) {
const secrets: Secret[] = await this.k8Factory.getK8(context).secrets().list(namespace);
for (const secret of secrets) {
secretsData.push({secret: secret.name, context: context});
}
}
const promises: Promise<void>[] = secretsData.map(async ({context, secret}): Promise<void> => {
await this.k8Factory.getK8(context).secrets().delete(namespace, secret);
});
await Promise.all(promises);
}
private async deletePvcs(namespace: NamespaceName, contexts: Context[]): Promise<void> {
const pvcsData: Array<{pvc: string; context: Context}> = [];
for (const context of contexts) {
const pvcs: string[] = await this.k8Factory.getK8(context).pvcs().list(namespace, []);
for (const pvc of pvcs) {
pvcsData.push({pvc, context});
}
}
const promises: Promise<void>[] = pvcsData.map(async ({context, pvc}): Promise<void> => {
await this.k8Factory
.getK8(context)
.pvcs()
.delete(PvcReference.of(namespace, PvcName.of(pvc)))
.catch();
});
await Promise.all(promises);
}
private async crdExists(context: string, crdName: string): Promise<boolean> {
return await this.k8Factory.getK8(context).crds().ifExists(crdName);
}
/**
* Ensure the PodLogs CRD from Grafana Alloy is installed
*/
private async ensurePodLogsCrd({contexts}: NetworkDeployConfigClass): Promise<void> {
const PODLOGS_CRD: string = 'podlogs.monitoring.grafana.com';
const CRD_FILE_PATH: string = 'operations/helm/charts/alloy/charts/crds/crds/monitoring.grafana.com_podlogs.yaml';
// Use the GitHub Contents API (api.github.com) instead of raw.githubusercontent.com.
//
// Why: raw.githubusercontent.com is served by the Fastly CDN and its rate-limiting
// behaviour for unauthenticated requests is undocumented — adding a token there may
// have no effect. The Contents API, on the other hand, is part of the GitHub REST API
// (api.github.com) whose rate limits are well-documented: 60 req/hour unauthenticated
// vs 5 000 req/hour when a valid token is supplied. Since GITHUB_TOKEN is injected
// automatically into every GitHub Actions job, CI runs always get the higher limit,
// making 429s far less likely in the first place.
const CRD_URL: string =
`https://api.github.com/repos/grafana/alloy/contents/${CRD_FILE_PATH}` +
`?ref=${versions.GRAFANA_PODLOGS_CRD_VERSION}`;
const CRD_RAW_URL: string = `https://raw.githubusercontent.com/grafana/alloy/${versions.GRAFANA_PODLOGS_CRD_VERSION}/${CRD_FILE_PATH}`;
const LOCAL_CRD_FILE: string = PathEx.join(
constants.ROOT_DIR,
'resources',
'crds',
`monitoring.grafana.com_podlogs-${versions.GRAFANA_PODLOGS_CRD_VERSION}.yaml`,
);
for (const context of contexts as string[]) {
const exists: boolean = await this.crdExists(context, PODLOGS_CRD);
if (exists) {
this.logger.debug(`CRD ${PODLOGS_CRD} already exists in context ${context}`);
continue;
}
this.logger.info(`Installing missing CRD ${PODLOGS_CRD} from ${CRD_URL} in context ${context}...`);
const temporaryFile: string = PathEx.join(
constants.SOLO_CACHE_DIR,
`podlogs-crd-${versions.GRAFANA_PODLOGS_CRD_VERSION}.yaml`,
);
// Download and cache the CRD YAML. The cache file is keyed by the CRD version so
// it is automatically invalidated when GRAFANA_PODLOGS_CRD_VERSION is bumped.
// SOLO_CACHE_DIR persists across job steps (unlike os.tmpdir() which is ephemeral),
// ensuring we only make one network request per job even if multiple contexts need
// the CRD installed.
if (!fs.existsSync(temporaryFile)) {
// Prefer a vendored CRD file to avoid external network/rate-limit failures in CI.
if (fs.existsSync(LOCAL_CRD_FILE)) {
fs.copyFileSync(LOCAL_CRD_FILE, temporaryFile);
this.logger.debug(`Using local PodLogs CRD file: ${LOCAL_CRD_FILE}`);
} else {
const downloadErrors: string[] = [];
// Attempt #1: GitHub Contents API.
// The response is a JSON envelope with base64 content.
const apiHeaders: Record<string, string> = {Accept: 'application/vnd.github.v3+json'};
if (process.env.GITHUB_TOKEN) {
apiHeaders['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const apiResponse: Response = await fetch(CRD_URL, {headers: apiHeaders});
if (apiResponse.ok) {
const json: {content: string} = (await apiResponse.json()) as {content: string};
const yamlContent: string = Buffer.from(json.content.replaceAll(/\s/g, ''), 'base64').toString('utf8');
fs.writeFileSync(temporaryFile, yamlContent, 'utf8');
} else {
const apiError: string = `${apiResponse.status} ${apiResponse.statusText}`.trim();
downloadErrors.push(`GitHub API: ${apiError}`);
this.logger.warn(`Failed to download PodLogs CRD from GitHub API (${apiError}), trying raw URL fallback.`);
// Attempt #2: raw.githubusercontent.com fallback.
const rawHeaders: Record<string, string> = {};
if (process.env.GITHUB_TOKEN) {
rawHeaders['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const rawResponse: Response = await fetch(CRD_RAW_URL, {headers: rawHeaders});
if (!rawResponse.ok) {
const rawError: string = `${rawResponse.status} ${rawResponse.statusText}`.trim();
downloadErrors.push(`Raw URL: ${rawError}`);
throw new Error(`Failed to download CRD YAML (${downloadErrors.join('; ')})`);
}
const yamlContent: string = await rawResponse.text();
fs.writeFileSync(temporaryFile, yamlContent, 'utf8');
}
}
}
await this.k8Factory.getK8(context).manifests().applyManifest(temporaryFile);
}
}
/**
* Ensure all Prometheus Operator CRDs exist; install chart only if needed.
* If all CRDs are already present or monitoring support is disabled, skip installation.
*/
/** Ensure Prometheus Operator CRDs are present; install missing ones via the chart */
private async ensurePrometheusOperatorCrds({
clusterRefs,
namespace,
deployment,
}: NetworkDeployConfigClass): Promise<void> {
const CRDS: {key: string; crd: string}[] = [
{key: 'alertmanagerconfigs', crd: 'alertmanagerconfigs.monitoring.coreos.com'},
{key: 'alertmanagers', crd: 'alertmanagers.monitoring.coreos.com'},
{key: 'podmonitors', crd: 'podmonitors.monitoring.coreos.com'},
{key: 'probes', crd: 'probes.monitoring.coreos.com'},
{key: 'prometheusagents', crd: 'prometheusagents.monitoring.coreos.com'},
{key: 'prometheuses', crd: 'prometheuses.monitoring.coreos.com'},
{key: 'prometheusrules', crd: 'prometheusrules.monitoring.coreos.com'},
{key: 'scrapeconfigs', crd: 'scrapeconfigs.monitoring.coreos.com'},
{key: 'servicemonitors', crd: 'servicemonitors.monitoring.coreos.com'},
{key: 'thanosrulers', crd: 'thanosrulers.monitoring.coreos.com'},
];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, context] of clusterRefs) {
let valuesArgument: string = '';
let missingCount: number = 0;
for (const {key, crd} of CRDS) {
const exists: boolean = await this.crdExists(context, crd);
if (exists) {
valuesArgument += ` --set "${key}.enabled=false"`;
} else {
missingCount++;
}
}
if (missingCount === 0) {
this.logger.info(`All Prometheus Operator CRDs already present in context ${context}; skipping installation.`);
continue;
}
const setupMap: Map<string, string> = new Map([
[constants.PROMETHEUS_OPERATOR_CRDS_RELEASE_NAME, constants.PROMETHEUS_OPERATOR_CRDS_CHART_URL],
]);
await this.chartManager.setup(setupMap);
await this.chartManager.install(
namespace,
constants.PROMETHEUS_OPERATOR_CRDS_RELEASE_NAME,
constants.PROMETHEUS_OPERATOR_CRDS_CHART,
constants.PROMETHEUS_OPERATOR_CRDS_CHART,
versions.PROMETHEUS_OPERATOR_CRDS_VERSION,
valuesArgument,
context,
);
this.eventBus.emit(new NetworkDeployedEvent(deployment));
showVersionBanner(
this.logger,
constants.PROMETHEUS_OPERATOR_CRDS_CHART,
versions.PROMETHEUS_OPERATOR_CRDS_VERSION,
);
}
}
/**
* Patch the ServiceMonitor created by the solo-deployment helm chart so that it is discovered
* by the kube-prometheus-stack Prometheus operator and targets the correct consensus node services.
*
* Two fixes are applied via a merge patch:
* 1. Adds the `release: <PROMETHEUS_RELEASE_NAME>` label so the Prometheus instance from
* kube-prometheus-stack (which selects ServiceMonitors by `release` label) can discover it.
* 2. Corrects `spec.selector.matchLabels` to `solo.hedera.com/type: network-node-svc` so the
* ServiceMonitor targets the non-headless consensus-node services (which expose the prometheus
* metrics port) rather than the hard-coded `network-node` value in the helm chart template.
*/
private async patchServiceMonitorForPrometheus(namespace: NamespaceName, context: Context): Promise<void> {
const patch: object = {
apiVersion: 'monitoring.coreos.com/v1',
kind: 'ServiceMonitor',
metadata: {
name: constants.SOLO_SERVICE_MONITOR_NAME,
namespace: namespace.name,
labels: {
release: constants.PROMETHEUS_RELEASE_NAME,
},
},
spec: {
selector: {
matchLabels: {
'solo.hedera.com/type': 'network-node-svc',
},
},
},
};