UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

586 lines (486 loc) 20.3 kB
// SPDX-License-Identifier: Apache-2.0 import * as x509 from '@peculiar/x509'; import {DataValidationError} from './errors/data-validation-error.js'; import {IllegalArgumentError} from './errors/illegal-argument-error.js'; import {MissingArgumentError} from './errors/missing-argument-error.js'; import {SoloError} from './errors/solo-error.js'; import * as constants from './constants.js'; import {type AccountId} from '@hiero-ledger/sdk'; import {type IP, type NodeAlias, type NodeAliases, type NodeId} from '../types/aliases.js'; import {PodName} from '../integration/kube/resources/pod/pod-name.js'; import {GrpcProxyTlsEnums} from './enumerations.js'; import {type NamespaceName} from '../types/namespace/namespace-name.js'; import { type ClusterReferenceName, type ComponentId, type NamespaceNameAsString, type NodeAliasToAddressMapping, type PriorityMapping, } from './../types/index.js'; import {PathEx} from '../business/utils/path-ex.js'; import {type ConsensusNode} from './model/consensus-node.js'; import {HEDERA_PLATFORM_VERSION} from '../../version.js'; import {OperatingSystem} from '../business/utils/operating-system.js'; export class Templates { public static renderNetworkPodName(nodeAlias: NodeAlias): PodName { return PodName.of(`network-${nodeAlias}-0`); } private static renderNetworkSvcName(nodeAlias: NodeAlias): string { return `network-${nodeAlias}-svc`; } public static renderNetworkHeadlessSvcName(nodeAlias: NodeAlias): string { return `network-${nodeAlias}`; } public static renderNodeAliasFromNumber(number_: number): NodeAlias { return `node${number_}`; } public static renderPostgresPodName(number_: number): PodName { return PodName.of(`solo-shared-resources-postgres-${number_}`); } public static renderNodeAliasesFromCount(count: number, existingNodesCount: number): NodeAliases { const nodeAliases: NodeAliases = []; let nodeNumber: number = existingNodesCount + 1; for (let index: number = 1; index <= count; index++) { nodeAliases.push(Templates.renderNodeAliasFromNumber(nodeNumber)); nodeNumber++; } return nodeAliases; } public static renderMirrorNodeDatabaseInitScriptUrl(release: string): string { return `https://raw.githubusercontent.com/hiero-ledger/hiero-mirror-node/refs/tags/${release}/importer/src/main/resources/db/scripts/init.sh`; } public static renderGossipPemPrivateKeyFile(nodeAlias: NodeAlias): string { return `${constants.SIGNING_KEY_PREFIX}-private-${nodeAlias}.pem`; } public static renderGossipPemPublicKeyFile(nodeAlias: NodeAlias): string { return `${constants.SIGNING_KEY_PREFIX}-public-${nodeAlias}.pem`; } public static renderTLSPemPrivateKeyFile(nodeAlias: NodeAlias): string { return `hedera-${nodeAlias}.key`; } public static renderTLSPemPublicKeyFile(nodeAlias: NodeAlias): string { return `hedera-${nodeAlias}.crt`; } public static renderNodeAdminKeyName(nodeAlias: NodeAlias): string { return `${nodeAlias}-admin`; } public static renderNodeFriendlyName(prefix: string, nodeAlias: NodeAlias, suffix: string = ''): string { const parts: string[] = [prefix, nodeAlias]; if (suffix) { parts.push(suffix); } return parts.join('-'); } public static extractNodeAliasFromPodName(podName: PodName): NodeAlias { const parts: string[] = podName.name.split('-'); if (parts.length !== 3) { throw new DataValidationError(`pod name is malformed : ${podName.name}`, 3, parts.length); } return parts[1].trim() as NodeAlias; } public static prepareReleasePrefix(tag: string): string { if (!tag) { throw new MissingArgumentError('tag cannot be empty'); } const parsed: string[] = tag.split('.'); if (parsed.length < 3) { throw new Error(`tag (${tag}) must include major, minor and patch fields (e.g. v0.40.4)`); } return `${parsed[0]}.${parsed[1]}`; } /** * renders the name to be used to store the new account key as a Kubernetes secret * @param accountId * @returns the name of the Kubernetes secret to store the account key */ public static renderAccountKeySecretName(accountId: AccountId | string): string { return `account-key-${accountId.toString()}`; } /** * renders the label selector to be used to fetch the new account key from the Kubernetes secret * @param accountId * @returns the label selector of the Kubernetes secret to retrieve the account key */ public static renderAccountKeySecretLabelSelector(accountId: AccountId | string): string { return `solo.hedera.com/account-id=${accountId.toString()}`; } /** * renders the label object to be used to store the new account key in the Kubernetes secret * @param accountId * @returns the label object to be used to store the new account key in the Kubernetes secret */ public static renderAccountKeySecretLabelObject(accountId: AccountId | string): { 'solo.hedera.com/account-id': string; } { return { 'solo.hedera.com/account-id': accountId.toString(), }; } public static renderDistinguishedName( nodeAlias: NodeAlias, state: string = 'TX', locality: string = 'Richardson', org: string = 'Hedera', orgUnit: string = 'Hedera', country: string = 'US', ): x509.Name { return new x509.Name(`CN=${nodeAlias},ST=${state},L=${locality},O=${org},OU=${orgUnit},C=${country}`); } public static renderStagingDir(cacheDirectory: string, releaseTagOverride: string): string { let releaseTag: string = releaseTagOverride; if (!cacheDirectory) { throw new IllegalArgumentError('cacheDirectory cannot be empty'); } if (!releaseTag) { releaseTag = HEDERA_PLATFORM_VERSION; } const releasePrefix: string = this.prepareReleasePrefix(releaseTag); if (!releasePrefix) { throw new IllegalArgumentError('releasePrefix cannot be empty'); } return PathEx.resolve(PathEx.join(cacheDirectory, releasePrefix, 'staging', releaseTag)); } public static localInstallationExecutableForDependency( dependency: string, installationDirectory: string = PathEx.join(constants.SOLO_HOME_DIR, 'bin'), ): string { switch (dependency) { case constants.HELM: case constants.KIND: case constants.PODMAN: case constants.VFKIT: case constants.GVPROXY: case constants.CRANE: case constants.KUBECTL: { if (OperatingSystem.isWin32()) { return PathEx.join(installationDirectory, `${dependency}.exe`); } return PathEx.join(installationDirectory, dependency); } default: { throw new SoloError(`unknown dependency: ${dependency}`); } } } public static renderFullyQualifiedNetworkPodName(namespace: NamespaceName, nodeAlias: NodeAlias): string { return `${Templates.renderNetworkPodName(nodeAlias)}.${Templates.renderNetworkHeadlessSvcName(nodeAlias)}.${namespace.name}.svc.cluster.local`; } public static renderFullyQualifiedNetworkSvcName(namespace: NamespaceName, nodeAlias: NodeAlias): string { return `${Templates.renderNetworkSvcName(nodeAlias)}.${namespace.name}.svc.cluster.local`; } public static nodeIdFromNodeAlias(nodeAlias: NodeAlias): NodeId { for (let index: number = nodeAlias.length - 1; index > 0; index--) { if (Number.isNaN(Number.parseInt(nodeAlias[index]))) { return Number.parseInt(nodeAlias.slice(index + 1)) - 1; } } throw new SoloError(`Can't get node id from node ${nodeAlias}`); } public static renderComponentIdFromNodeId(nodeId: NodeId): ComponentId { return nodeId + 1; } public static renderComponentIdFromNodeAlias(nodeAlias: NodeAlias): ComponentId { return this.nodeIdFromNodeAlias(nodeAlias) + 1; } public static renderNodeIdFromComponentId(componentId: ComponentId): NodeId { return componentId - 1; } public static renderGossipKeySecretName(nodeAlias: NodeAlias): string { return `network-${nodeAlias}-keys-secrets`; } public static renderGossipKeySecretLabelObject(nodeAlias: NodeAlias): {'solo.hedera.com/node-name': string} { return {'solo.hedera.com/node-name': nodeAlias}; } /** * Creates the secret name based on the node alias type * * @param nodeAlias - node alias * @param type - whether is for gRPC or gRPC Web ( Haproxy or Envoy ) * * @returns the appropriate secret name */ public static renderGrpcTlsCertificatesSecretName(nodeAlias: NodeAlias, type: GrpcProxyTlsEnums): string { switch (type) { //? HAProxy Proxy case GrpcProxyTlsEnums.GRPC: { return `haproxy-proxy-secret-${nodeAlias}`; } //? Envoy Proxy case GrpcProxyTlsEnums.GRPC_WEB: { return `envoy-proxy-secret-${nodeAlias}`; } } } /** * Creates the secret labels based on the node alias type * * @param nodeAlias - node alias * @param type - whether is for gRPC or gRPC Web ( Haproxy or Envoy ) * * @returns the appropriate secret labels */ public static renderGrpcTlsCertificatesSecretLabelObject( nodeAlias: NodeAlias, type: GrpcProxyTlsEnums, ): Record<string, string> { switch (type) { //? HAProxy Proxy case GrpcProxyTlsEnums.GRPC: { return {'haproxy-proxy-secret': nodeAlias}; } //? Envoy Proxy case GrpcProxyTlsEnums.GRPC_WEB: { return {'envoy-proxy-secret': nodeAlias}; } } } public static parseNodeAliasToIpMapping(unparsed: string): Record<NodeAlias, IP> { const mapping: Record<NodeAlias, IP> = {}; for (const data of unparsed.split(',')) { const [nodeAlias, ip] = data.split('=') as [NodeAlias, IP]; mapping[nodeAlias] = ip; } return mapping; } /** * Parses a comma-separated string into a mapping of node aliases → address/port. * * Accepted input formats: * 1) Explicit alias → address[:port] * Each entry provides the node alias and the target address, optionally with a port. * Example: "node1=127.0.0.1:8080,node2=127.0.0.1:8081" * * 2) Explicit alias → address (no port) * Same as above, but if the port is omitted it defaults to 8080. * Example: "node1=localhost,node2=localhost:8081" * * 3) Address[:port] only (no aliases) * Aliases are inferred from the `nodes` array by index order. * If the port is omitted, it defaults to 8080. * Example: "localhost,127.0.0.2:8081" * * @param unparsed - Input string describing alias/address[:port] mappings. * @param nodes - Used to infer aliases when not explicitly provided. * @returns Record keyed by NodeAlias with resolved address and port. * * @throws SoloError if an alias cannot be inferred. */ public static parseNodeAliasToAddressAndPortMapping( unparsed: string, nodes: ConsensusNode[], ): NodeAliasToAddressMapping { const mapping: NodeAliasToAddressMapping = {}; if (!unparsed || typeof unparsed !== 'string') { return mapping; } for (const [index, data] of unparsed.split(',').entries()) { const [nodeAlias, addressData] = data.includes('=') ? (data.split('=') as [NodeAlias, IP]) : [nodes[index]?.name, data]; if (!nodeAlias) { throw new SoloError(`Node alias for ${addressData} cannot be inferred`); } const [address, port] = addressData.includes(':') ? addressData.split(':') : [addressData, '8080']; mapping[nodeAlias] = {address, port: +port}; } return mapping; } public static parseNodeAliasToDomainNameMapping(unparsed: string): Record<NodeAlias, string> { const mapping: Record<NodeAlias, string> = {}; for (const data of unparsed.split(',')) { const [nodeAlias, domainName] = data.split('=') as [NodeAlias, string]; if (!nodeAlias || typeof nodeAlias !== 'string') { throw new SoloError(`Can't parse node alias: ${data}`); } if (!domainName || typeof domainName !== 'string') { throw new SoloError(`Can't parse domain name: ${data}`); } mapping[nodeAlias] = domainName; } return mapping; } /** * Renders the fully qualified domain name for a consensus node. We support the following variables for templating * in the dnsConsensusNodePattern: {nodeAlias}, {nodeId}, {namespace}, {cluster} * * The end result will be `${dnsConsensusNodePattern}.${dnsBaseDomain}`. * For example, if the dnsConsensusNodePattern is `network-{nodeAlias}-svc.{namespace}.svc` and the dnsBaseDomain is `cluster.local`, * the fully qualified domain name will be `network-{nodeAlias}-svc.{namespace}.svc.cluster.local`. * @param nodeAlias - the alias of the consensus node * @param nodeId - the id of the consensus node * @param namespace - the namespace of the consensus node * @param cluster - the cluster of the consensus node * @param dnsBaseDomain - the base domain of the cluster * @param dnsConsensusNodePattern - the pattern to use for the consensus node */ public static renderConsensusNodeFullyQualifiedDomainName( nodeAlias: string, nodeId: number, namespace: NamespaceNameAsString, cluster: ClusterReferenceName, dnsBaseDomain: string, dnsConsensusNodePattern: string, ): string { const searchReplace: Record<string, string> = { '{nodeAlias}': nodeAlias, '{nodeId}': nodeId.toString(), '{namespace}': namespace, '{cluster}': cluster, }; for (const [search, replace] of Object.entries(searchReplace)) { dnsConsensusNodePattern = dnsConsensusNodePattern.replace(search, replace); } return `${dnsConsensusNodePattern}.${dnsBaseDomain}`; } /** * @param serviceName - name of the service * @param namespace - the pattern to use for the consensus node * @param dnsBaseDomain - the base domain of the cluster */ public static renderSvcFullyQualifiedDomainName( serviceName: string, namespace: NamespaceNameAsString, dnsBaseDomain: string, ): string { return `${serviceName}.${namespace}.svc.${dnsBaseDomain}`; } // Component Label Selectors public static renderRelayLabels(id: ComponentId, legacyReleaseName?: string): string[] { return legacyReleaseName ? [`app.kubernetes.io/instance=${legacyReleaseName}`, 'app.kubernetes.io/name=relay'] : [`app.kubernetes.io/instance=${constants.JSON_RPC_RELAY_RELEASE_NAME}-${id}`, 'app.kubernetes.io/name=relay']; } public static renderHaProxyLabels(id: ComponentId): string[] { const nodeAlias: NodeAlias = Templates.renderNodeAliasFromNumber(id); return [`app=haproxy-${nodeAlias}`, 'solo.hedera.com/type=haproxy']; } public static renderMirrorNodeLabels(id: ComponentId, legacyReleaseName?: string): string[] { const releaseName: string = legacyReleaseName ?? Templates.renderMirrorNodeName(id); return [ 'app.kubernetes.io/name=importer', 'app.kubernetes.io/component=importer', `app.kubernetes.io/instance=${releaseName}`, ]; } public static renderMirrorIngressControllerLabels(): string[] { return [constants.SOLO_INGRESS_CONTROLLER_NAME_LABEL]; } public static renderEnvoyProxyLabels(id: ComponentId): string[] { const nodeAlias: NodeAlias = Templates.renderNodeAliasFromNumber(id); return [`solo.hedera.com/node-name=${nodeAlias}`, 'solo.hedera.com/type=envoy-proxy']; } public static renderExplorerLabels(id: ComponentId, legacyReleaseName?: string): string[] { const releaseName: string = legacyReleaseName ?? `${constants.EXPLORER_RELEASE_NAME}-${id}`; return [`app.kubernetes.io/instance=${releaseName}`]; } public static renderConsensusNodeLabels(id: ComponentId): string[] { return [`app=network-${Templates.renderNodeAliasFromNumber(id)}`]; } public static renderBlockNodeLabels(id: ComponentId, legacyReleaseName?: string): string[] { const releaseName: string = legacyReleaseName ?? Templates.renderBlockNodeName(id); return [`app.kubernetes.io/name=${releaseName}`]; } public static renderExplorerName(id: ComponentId): string { return `${constants.EXPLORER_RELEASE_NAME}-${id}`; } public static renderRelayName(id: ComponentId): string { return `${constants.JSON_RPC_RELAY_RELEASE_NAME}-${id}`; } public static renderBlockNodeName(id: ComponentId): string { return `${constants.BLOCK_NODE_RELEASE_NAME}-${id}`; } public static renderMirrorNodeName(id: ComponentId): string { return `${constants.MIRROR_NODE_RELEASE_NAME}-${id}`; } public static renderConfigMapRemoteConfigLabels(): string[] { return ['solo.hedera.com/type=remote-config']; } public static renderNodeLabelsFromNodeAlias(nodeAlias: NodeAlias): string[] { return [`solo.hedera.com/node-name=${nodeAlias}`, 'solo.hedera.com/type=network-node']; } public static renderNodeSvcLabelsFromNodeId(nodeId: NodeId): string[] { return [`solo.hedera.com/node-id=${nodeId},solo.hedera.com/type=network-node-svc`]; } /** * Build label selectors for deployment refresh by component type. */ public static renderComponentLabelSelectors(componentType: string, id: ComponentId): string[] { switch (componentType) { case 'ConsensusNode': { return Templates.renderHaProxyLabels(id); } case 'HaProxy': { return Templates.renderHaProxyLabels(id); } case 'BlockNode': { return Templates.renderBlockNodeLabels(id); } case 'MirrorNode': { return Templates.renderMirrorIngressControllerLabels(); } case 'RelayNode': { return Templates.renderRelayLabels(id); } case 'Explorer': { return Templates.renderExplorerLabels(id); } default: { return []; } } } public static parseExternalBlockAddress(raw: string): [string, number] { const [address, port] = raw.includes(':') ? raw.split(':') : [raw, constants.BLOCK_NODE_PORT]; return [address, +port]; } public static parseBlockNodePriorityMapping(rawString: string, nodes: ConsensusNode[]): Record<NodeAlias, number> { const mapping: Record<NodeAlias, number> = {}; const isDefault: boolean = !rawString || rawString.split(',').length === 0; const nodeAliasesToPriorityMapping: string[] = isDefault ? nodes.map((node): NodeAlias => node.name) : rawString.split(','); for (const data of nodeAliasesToPriorityMapping) { // eslint-disable-next-line prefer-const let [nodeAlias, priority] = data.split('=') as [NodeAlias, number | undefined]; mapping[nodeAlias] = +priority || 1; } return mapping; } /** * @param rawString - the raw string from the unparsed [flags.blockNodeMapping, flags.externalBlockNodeMapping] * @param fallbackBlockNodeIds - either block node IDs or external block node IDs */ public static parseConsensusNodePriorityMapping( rawString: string, fallbackBlockNodeIds: ComponentId[], ): PriorityMapping[] { // if no nodes are specified use one and set highest priority to first block node const useDefault: boolean = typeof rawString !== 'string' || rawString.length === 0; if (useDefault) { const mapping: PriorityMapping[] = []; for (const [index, blockNodeId] of fallbackBlockNodeIds.entries()) { // set higher priority to first node mapping.push([blockNodeId, index === 0 ? 2 : 1]); } return mapping; } // Figure out if any priority is explicitly specified const hasPriority: boolean = rawString.includes('='); const mapping: PriorityMapping[] = []; for (const [index, data] of rawString.split(',').entries()) { // use specified priority if set if (data.includes('=')) { const [blockNodeId, priority] = data.split('='); mapping.push([+blockNodeId, +priority]); } // if any node has priority specified, default all unset to 1 else if (hasPriority) { mapping.push([+data, 1]); } // if no explicit priority is specified, set higher priority to first node else { mapping.push([+data, index === 0 ? 2 : 1]); } } return mapping; } }