UNPKG

@hashgraph/solo

Version:

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

730 lines (624 loc) 28.7 kB
// SPDX-License-Identifier: Apache-2.0 import {inject, injectable} from 'tsyringe-neo'; import {type ObjectMapper} from '../../../../data/mapper/api/object-mapper.js'; import {ReadRemoteConfigBeforeLoadError} from '../../../errors/read-remote-config-before-load-error.js'; import {WriteRemoteConfigBeforeLoadError} from '../../../errors/write-remote-config-before-load-error.js'; import {RemoteConfigSource} from '../../../../data/configuration/impl/remote-config-source.js'; import {YamlConfigMapStorageBackend} from '../../../../data/backend/impl/yaml-config-map-storage-backend.js'; import {type ConfigMap} from '../../../../integration/kube/resources/config-map/config-map.js'; import {LedgerPhase} from '../../../../data/schema/model/remote/ledger-phase.js'; import {ComponentsDataWrapperApi} from '../../../../core/config/remote/api/components-data-wrapper-api.js'; import {InjectTokens} from '../../../../core/dependency-injection/inject-tokens.js'; import {type K8Factory} from '../../../../integration/kube/k8-factory.js'; import {type SoloLogger} from '../../../../core/logging/solo-logger.js'; import {type ConfigManager} from '../../../../core/config-manager.js'; import {patchInject} from '../../../../core/dependency-injection/container-helper.js'; import { type ClusterReferenceName, type ClusterReferences, type Context, type DeploymentName, type NamespaceNameAsString, Optional, } from '../../../../types/index.js'; import { type AnyObject, type ArgvStruct, type NodeAlias, type NodeAliases, type NodeId, } from '../../../../types/aliases.js'; import {NamespaceName} from '../../../../types/namespace/namespace-name.js'; import {ComponentStateMetadataSchema} from '../../../../data/schema/model/remote/state/component-state-metadata-schema.js'; import {Templates} from '../../../../core/templates.js'; import {DeploymentPhase} from '../../../../data/schema/model/remote/deployment-phase.js'; import {getSoloVersion} from '../../../../../version.js'; import * as constants from '../../../../core/constants.js'; import {SoloError} from '../../../../core/errors/solo-error.js'; import {Flags as flags} from '../../../../commands/flags.js'; import {promptTheUserForDeployment} from '../../../../core/resolvers.js'; import {ConsensusNode} from '../../../../core/model/consensus-node.js'; import {RemoteConfigRuntimeStateApi} from '../../api/remote-config-runtime-state-api.js'; import {type RemoteConfigValidatorApi} from '../../../../core/config/remote/api/remote-config-validator-api.js'; import {ComponentFactoryApi} from '../../../../core/config/remote/api/component-factory-api.js'; import {ComponentTypes} from '../../../../core/config/remote/enumerations/component-types.js'; import {LocalConfigRuntimeState} from '../local/local-config-runtime-state.js'; import {RemoteConfigMetadataSchema} from '../../../../data/schema/model/remote/remote-config-metadata-schema.js'; import {ApplicationVersionsSchema} from '../../../../data/schema/model/common/application-versions-schema.js'; import {ClusterSchema} from '../../../../data/schema/model/common/cluster-schema.js'; import {DeploymentStateSchema} from '../../../../data/schema/model/remote/deployment-state-schema.js'; import {DeploymentHistorySchema} from '../../../../data/schema/model/remote/deployment-history-schema.js'; import {RemoteConfigSchemaDefinition} from '../../../../data/schema/migration/impl/remote/remote-config-schema-definition.js'; import {RemoteConfigSchema} from '../../../../data/schema/model/remote/remote-config-schema.js'; import {ConsensusNodeStateSchema} from '../../../../data/schema/model/remote/state/consensus-node-state-schema.js'; import {UserIdentitySchema} from '../../../../data/schema/model/common/user-identity-schema.js'; import {Deployment} from '../local/deployment.js'; import {RemoteConfig} from './remote-config.js'; import {ComponentIdsSchema} from '../../../../data/schema/model/remote/state/component-ids-schema.js'; import * as helpers from '../../../../core/helpers.js'; import {ResourceNotFoundError} from '../../../../integration/kube/errors/resource-operation-errors.js'; import {MissingRequiredParametersError} from '../../errors/missing-required-parameters-error.js'; import {SemanticVersion} from '../../../utils/semantic-version.js'; enum RuntimeStatePhase { Loaded = 'loaded', NotLoaded = 'not_loaded', } interface VersionField { value: SemanticVersion<string>; } @injectable() export class RemoteConfigRuntimeState implements RemoteConfigRuntimeStateApi { private static readonly SOLO_REMOTE_CONFIGMAP_DATA_KEY: string = 'remote-config-data'; private phase: RuntimeStatePhase = RuntimeStatePhase.NotLoaded; public clusterReferences: Map<Context, ClusterReferenceName> = new Map(); private namespace: NamespaceName; private source?: RemoteConfigSource; private backend?: YamlConfigMapStorageBackend; private _remoteConfig?: RemoteConfig; public constructor( @inject(InjectTokens.K8Factory) private readonly k8Factory?: K8Factory, @inject(InjectTokens.SoloLogger) private readonly logger?: SoloLogger, @inject(InjectTokens.LocalConfigRuntimeState) private readonly localConfig?: LocalConfigRuntimeState, @inject(InjectTokens.ConfigManager) private readonly configManager?: ConfigManager, @inject(InjectTokens.RemoteConfigValidator) private readonly remoteConfigValidator?: RemoteConfigValidatorApi, @inject(InjectTokens.ObjectMapper) private readonly objectMapper?: ObjectMapper, ) { this.k8Factory = patchInject(k8Factory, InjectTokens.K8Factory, this.constructor.name); this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name); this.localConfig = patchInject(localConfig, InjectTokens.LocalConfigRuntimeState, this.constructor.name); this.configManager = patchInject(configManager, InjectTokens.ConfigManager, this.constructor.name); this.remoteConfigValidator = patchInject( remoteConfigValidator, InjectTokens.RemoteConfigValidator, this.constructor.name, ); this.objectMapper = patchInject(objectMapper, InjectTokens.ObjectMapper, this.constructor.name); } public get configuration(): RemoteConfig { this.failIfNotLoaded(); return this._remoteConfig; } public get components(): Readonly<ComponentsDataWrapperApi> { this.failIfNotLoaded(); return this._remoteConfig.components; } public get currentCluster(): ClusterReferenceName { return this.k8Factory.default().clusters().readCurrent(); } public async load(namespace?: NamespaceName, context?: Context): Promise<void> { if (this.isLoaded()) { return; } await this.populateFromExisting(namespace, context); } private async populateFromConfigMap(configMap: ConfigMap, remoteConfig?: RemoteConfigSchema): Promise<void> { this.backend = new YamlConfigMapStorageBackend(configMap); this.source = new RemoteConfigSource( new RemoteConfigSchemaDefinition(this.objectMapper), this.objectMapper, this.backend, ); await this.source.load(); if (remoteConfig) { this.source.setModelData(remoteConfig); } this._remoteConfig = new RemoteConfig(this.source.modelData); this.phase = RuntimeStatePhase.Loaded; } private async updateConfigMap( context: Context, namespace: NamespaceName, data: Record<string, string>, ): Promise<void> { await this.k8Factory.getK8(context).configMaps().update(namespace, constants.SOLO_REMOTE_CONFIGMAP_NAME, data); } public isLoaded(): boolean { return this.phase === RuntimeStatePhase.Loaded; } private failIfNotLoaded(): void { if (!this.isLoaded()) { throw new ReadRemoteConfigBeforeLoadError('Attempting to read from remote config before loading it'); } } public async persist(): Promise<void> { if (!this.isLoaded()) { throw new WriteRemoteConfigBeforeLoadError('Attempting to persist remote config before loading it'); } await this.source.persist(); const remoteConfigDataBytes: Buffer = await this.backend.readBytes( RemoteConfigRuntimeState.SOLO_REMOTE_CONFIGMAP_DATA_KEY, ); const remoteConfigData: Record<string, string> = { [RemoteConfigRuntimeState.SOLO_REMOTE_CONFIGMAP_DATA_KEY]: remoteConfigDataBytes.toString('utf8'), }; const promises: Promise<void>[] = []; for (const context of this.clusterReferences.keys()) { promises.push(this.updateConfigMap(context, this.namespace, remoteConfigData)); } await Promise.all(promises); } public async create( argv: ArgvStruct, ledgerPhase: LedgerPhase, nodeAliases: NodeAliases, namespace: NamespaceName, deploymentName: DeploymentName, clusterReference: ClusterReferenceName, context: Context, dnsBaseDomain: string, dnsConsensusNodePattern: string, ): Promise<void> { this.populateClusterReferences(deploymentName); const consensusNodeStates: ConsensusNodeStateSchema[] = nodeAliases.map( (nodeAlias: NodeAlias): ConsensusNodeStateSchema => { return new ConsensusNodeStateSchema( new ComponentStateMetadataSchema( Templates.renderComponentIdFromNodeAlias(nodeAlias), namespace.name, clusterReference, DeploymentPhase.REQUESTED, ), ); }, ); const userIdentity: Readonly<UserIdentitySchema> = this.localConfig.configuration.userIdentity; const cliVersion: SemanticVersion<string> = new SemanticVersion<string>(getSoloVersion()); const command: string = argv._.join(' '); const cluster: ClusterSchema = new ClusterSchema( clusterReference, namespace.name, deploymentName, dnsBaseDomain, dnsConsensusNodePattern, ); const remoteConfig: RemoteConfigSchema = new RemoteConfigSchema( 6, new RemoteConfigMetadataSchema(new Date(), userIdentity), new ApplicationVersionsSchema(cliVersion), [cluster], new DeploymentStateSchema(ledgerPhase, new ComponentIdsSchema(nodeAliases.length + 1), consensusNodeStates), new DeploymentHistorySchema([command], command), ); const configMap: ConfigMap = await this.createConfigMap(namespace, context); await this.populateFromConfigMap(configMap, remoteConfig); await this.persist(); } public async createFromExisting( namespace: NamespaceName, clusterReference: ClusterReferenceName, deploymentName: DeploymentName, componentFactory: ComponentFactoryApi, dnsBaseDomain: string, dnsConsensusNodePattern: string, existingClusterContext: Context, argv: ArgvStruct, nodeAliases: NodeAliases, ): Promise<void> { await this.populateFromExisting(namespace, existingClusterContext); this.populateClusterReferences(deploymentName); const newClusterContext: Context = this.localConfig.configuration.clusterRefs .get(clusterReference.toString()) ?.toString(); //? Create copy of the existing remote config inside the new cluster await this.createConfigMap(namespace, newClusterContext); await this.persist(); //* update the command history this.addCommandToHistory(argv._.join(' ')); //* add the new clusters this.configuration.addCluster( new ClusterSchema(clusterReference, namespace.name, deploymentName, dnsBaseDomain, dnsConsensusNodePattern), ); //* add the new nodes to components for (const nodeAlias of nodeAliases) { this.configuration.components.addNewComponent( componentFactory.createNewConsensusNodeComponent( Templates.renderComponentIdFromNodeAlias(nodeAlias), clusterReference, namespace, DeploymentPhase.REQUESTED, ), ComponentTypes.ConsensusNode, ); } await this.persist(); } public addCommandToHistory(command: string): void { this.source.modelData.history.commands.push(command); this.source.modelData.history.lastExecutedCommand = command; if (this.source.modelData.history.commands.length > constants.SOLO_REMOTE_CONFIG_MAX_COMMAND_IN_HISTORY) { this.source.modelData.history.commands.shift(); } } public async createConfigMap(namespace: NamespaceName, context: Context): Promise<ConfigMap> { const name: string = constants.SOLO_REMOTE_CONFIGMAP_NAME; const labels: Record<string, string> = constants.SOLO_REMOTE_CONFIGMAP_LABELS; await this.k8Factory .getK8(context) .configMaps() .create(namespace, name, labels, {[RemoteConfigRuntimeState.SOLO_REMOTE_CONFIGMAP_DATA_KEY]: '{}'}); return await this.k8Factory.getK8(context).configMaps().read(namespace, name); } private async getConfigMap(namespace: NamespaceName, context: Context): Promise<ConfigMap> { if (!namespace || !context) { throw new MissingRequiredParametersError( `Namespace and context are required to get the remote config ConfigMap, received namespace: ${namespace}, context: ${context}`, ); } let configMap: ConfigMap; try { configMap = await this.k8Factory .getK8(context) .configMaps() .read(namespace, constants.SOLO_REMOTE_CONFIGMAP_NAME); } catch (error) { throw error instanceof ResourceNotFoundError ? error : new SoloError( `Failed to get remote config ConfigMap for namespace: ${namespace}, context: ${context}. Error: ${error.message}`, error, ); } if (!configMap) { throw new SoloError(`Remote config ConfigMap not found for namespace: ${namespace}, context: ${context}`); } return configMap; } public async populateFromExisting(namespace: NamespaceName, context: Context): Promise<void> { const remoteConfigConfigMap: ConfigMap = await this.getConfigMap(namespace, context); await this.populateFromConfigMap(remoteConfigConfigMap); } public async remoteConfigExists(namespace: NamespaceName, context: Context): Promise<boolean> { const configMap: ConfigMap = await this.getConfigMap(namespace, context); return !!configMap; } public populateClusterReferences(deploymentName: DeploymentName): Context { let deployment: Deployment; try { deployment = this.localConfig.configuration.deploymentByName(deploymentName); } catch { // Deployment not in local config — fall back to namespace/context already resolved from remote config scan. const namespaceFromConfig: NamespaceName = this.configManager.getFlag(flags.namespace); if (namespaceFromConfig) { this.namespace = namespaceFromConfig; } return this.configManager.getFlag<Context>(flags.context); } this.namespace = NamespaceName.of(deployment.namespace); for (const clusterReference of deployment.clusters) { const context: Context = this.localConfig.configuration.clusterRefs.get(clusterReference.toString())?.toString(); this.clusterReferences.set(context, clusterReference.toString()); } return this.localConfig.configuration.clusterRefs.get(deployment.clusters.get(0)?.toString())?.toString(); } /** * Performs the loading of the remote configuration. * Checks if the configuration is already loaded, otherwise loads and adds the command to history. * * @param argv - arguments containing command input for historical reference. * @param validate - whether to validate the remote configuration. * @param [skipConsensusNodesValidation] - whether or not to validate the consensusNodes */ public async loadAndValidate( argv: {_: string[]} & AnyObject, validate: boolean = true, skipConsensusNodesValidation: boolean = true, ): Promise<void> { await this.setDefaultNamespaceAndDeploymentIfNotSet(argv); await this.setDefaultContextIfNotSet(); // Sync resolved context back to argv so subsequent configManager.update(argv) preserves it. argv[flags.context.name] ||= this.configManager.getFlag<Context>(flags.context); const deploymentName: DeploymentName = this.configManager.getFlag(flags.deployment); const context: Context = this.populateClusterReferences(deploymentName); // TODO: Compare configs from clusterReferences await this.load(this.namespace, context); this.logger.info('Remote config loaded'); if (!validate) { return; } await this.remoteConfigValidator.validateComponents( this.namespace, skipConsensusNodesValidation, this.configuration.state, ); const currentCommand: string = argv._?.join(' '); const commandArguments: string = flags.stringifyArgv(argv); this.addCommandToHistory( `Executed by ${this.localConfig.configuration.userIdentity.name}: ${currentCommand} ${commandArguments}`.trim(), ); this.initializeComponentVersions(argv, this.source.modelData); await this.persist(); } private initializeComponentVersions(argv: AnyObject, remoteConfig: RemoteConfigSchema): void { remoteConfig.versions.chart = argv[flags.soloChartVersion.name] ? new SemanticVersion(argv[flags.soloChartVersion.name]) : new SemanticVersion(flags.soloChartVersion.definition.defaultValue as string); // set default versions if not set const componentTypes: ComponentTypes[] = [ ComponentTypes.BlockNode, ComponentTypes.RelayNodes, ComponentTypes.MirrorNode, ComponentTypes.Explorer, ComponentTypes.ConsensusNode, ]; for (const componentType of componentTypes) { const version: SemanticVersion<string> = this.getComponentVersion(componentType); if (version.equals('0.0.0')) { switch (componentType) { case ComponentTypes.BlockNode: { this.updateComponentVersion( componentType, new SemanticVersion<string>(flags.blockNodeChartVersion.definition.defaultValue as string), ); break; } case ComponentTypes.RelayNodes: { this.updateComponentVersion( componentType, new SemanticVersion<string>(flags.relayReleaseTag.definition.defaultValue as string), ); break; } case ComponentTypes.MirrorNode: { this.updateComponentVersion( componentType, new SemanticVersion<string>(flags.mirrorNodeVersion.definition.defaultValue as string), ); break; } case ComponentTypes.Explorer: { this.updateComponentVersion( componentType, new SemanticVersion<string>(flags.explorerVersion.definition.defaultValue as string), ); break; } case ComponentTypes.ConsensusNode: { this.updateComponentVersion( componentType, new SemanticVersion<string>(flags.releaseTag.definition.defaultValue as string), ); break; } default: { throw new SoloError(`Unsupported component type: ${componentType}`); } } } } } public async deleteComponents(): Promise<void> { this._remoteConfig.state.consensusNodes = []; this._remoteConfig.state.blockNodes = []; this._remoteConfig.state.envoyProxies = []; this._remoteConfig.state.haProxies = []; this._remoteConfig.state.explorers = []; this._remoteConfig.state.mirrorNodes = []; this._remoteConfig.state.relayNodes = []; await this.persist(); } private async setDefaultNamespaceAndDeploymentIfNotSet(argv: AnyObject): Promise<void> { let namespaceFromConfig: NamespaceNameAsString = this.configManager.getFlag(flags.namespace); let deploymentName: DeploymentName = this.configManager.getFlag(flags.deployment); const deploymentFromArgv: DeploymentName = argv[flags.deployment.name] as DeploymentName; const namespaceFromArgv: NamespaceNameAsString = argv[flags.namespace.name] as NamespaceNameAsString; // Keep config manager in sync when deployment/namespace are resolved directly in argv by caller logic. if (!deploymentName && deploymentFromArgv) { this.configManager.setFlag(flags.deployment, deploymentFromArgv); deploymentName = deploymentFromArgv; } // Keep config manager in sync when namespace is resolved directly in argv by caller logic. // argv takes precedence over the default namespace that middleware may have set from kubectl context. if (namespaceFromArgv) { this.configManager.setFlag(flags.namespace, namespaceFromArgv); namespaceFromConfig = namespaceFromArgv; } if (namespaceFromConfig && deploymentName) { return; } // TODO: Current quick fix for commands where namespace is not passed let currentDeployment: Deployment = this.localConfig.configuration.deploymentByName(deploymentName); if (!deploymentName) { deploymentName = await promptTheUserForDeployment(this.configManager); currentDeployment = this.localConfig.configuration.deploymentByName(deploymentName); // TODO: Fix once we have the DataManager, // without this the user will be prompted a second time for the deployment // TODO: we should not be mutating argv argv[flags.deployment.name] = deploymentName; this.logger.warn( `Deployment name not found in flags or local config, setting it in argv and config manager to: ${deploymentName}`, ); this.configManager.setFlag(flags.deployment, deploymentName); } if (!currentDeployment) { throw new SoloError(`Selected deployment name is not set in local config - ${deploymentName}`); } const namespace: NamespaceNameAsString = currentDeployment.namespace; this.logger.warn(`Namespace not found in flags, setting it to: ${namespace}`); this.configManager.setFlag(flags.namespace, namespace); argv[flags.namespace.name] = namespace; } private async setDefaultContextIfNotSet(): Promise<void> { if (this.configManager.hasFlag(flags.context)) { return; } let context: Context; try { context = await this.getContextForFirstCluster(); } catch { context = this.k8Factory.default().contexts().readCurrent(); } if (!context) { throw new SoloError("Context is not passed and default one can't be acquired"); } this.logger.warn(`Context not found in flags, setting it to: ${context}`); this.configManager.setFlag(flags.context, context); } //* Common Commands /** * Get the consensus nodes from the remoteConfig and use the localConfig to get the context * @returns an array of ConsensusNode objects */ public getConsensusNodes(): ConsensusNode[] { if (!this.isLoaded()) { throw new SoloError('Remote configuration is not loaded, and was expected to be loaded'); } const consensusNodes: ConsensusNode[] = []; for (const node of Object.values(this.configuration.state.consensusNodes)) { const cluster: ClusterSchema = this.configuration.clusters.find( (cluster: ClusterSchema): boolean => cluster.name === node.metadata.cluster, ); const context: Context = this.localConfig.configuration.clusterRefs.get(node.metadata.cluster)?.toString() ?? this.configManager.getFlag(flags.context); const nodeAlias: NodeAlias = Templates.renderNodeAliasFromNumber(node.metadata.id); const nodeId: NodeId = Templates.renderNodeIdFromComponentId(node.metadata.id); consensusNodes.push( new ConsensusNode( nodeAlias, nodeId, node.metadata.namespace, node.metadata.cluster, context, cluster.dnsBaseDomain, cluster.dnsConsensusNodePattern, Templates.renderConsensusNodeFullyQualifiedDomainName( nodeAlias, nodeId, node.metadata.namespace, node.metadata.cluster, cluster.dnsBaseDomain, cluster.dnsConsensusNodePattern, ), node.blockNodeMap, node.externalBlockNodeMap, ), ); } // return the consensus nodes return consensusNodes; } /** * Gets a list of distinct contexts from the consensus nodes. * @returns an array of context strings. */ public getContexts(): Context[] { return [...new Set(this.getConsensusNodes().map((node): Context => node.context))]; } /** * Gets a list of distinct cluster references from the consensus nodes. * @returns an object of cluster references. */ public getClusterRefs(): ClusterReferences { const nodes: ConsensusNode[] = this.getConsensusNodes(); const accumulator: ClusterReferences = new Map<string, string>(); for (const node of nodes) { accumulator.set(node.cluster, node.context); } return accumulator; } private async getContextForFirstCluster(): Promise<string> { const deploymentName: DeploymentName = this.configManager.getFlag(flags.deployment); const clusterReference: ClusterReferenceName = this.localConfig.configuration.deploymentByName(deploymentName)?.clusters?.get(0)?.toString() ?? this.k8Factory.default().clusters().readCurrent(); const context: Context = this.localConfig.configuration.clusterRefs.get(clusterReference)?.toString(); this.logger.debug(`Using context ${context} for cluster ${clusterReference} for deployment ${deploymentName}`); return context; } public getNamespace(): NamespaceName { return NamespaceName.of(this.configuration.clusters?.at(0)?.namespace); } public extractContextFromConsensusNodes(nodeAlias: NodeAlias): Optional<string> { return helpers.extractContextFromConsensusNodes(nodeAlias, this.getConsensusNodes()); } public updateComponentVersion(type: ComponentTypes, version: SemanticVersion<string>): void { const updateVersionCallback: (versionField: VersionField) => void = (versionField: VersionField): void => { versionField.value = version; }; this.applyCallbackToVersionField(type, updateVersionCallback); } /** * Method used to map the component type to the specific version field * and pass it to a callback to apply modifications */ private applyCallbackToVersionField( componentType: ComponentTypes, callback: (versionField: VersionField) => void, ): void { switch (componentType) { case ComponentTypes.ConsensusNode: { const versionField: VersionField = {value: this.configuration.versions.consensusNode}; callback(versionField); this.configuration.versions.consensusNode = versionField.value; break; } case ComponentTypes.MirrorNode: { const versionField: VersionField = {value: this.configuration.versions.mirrorNodeChart}; callback(versionField); this.configuration.versions.mirrorNodeChart = versionField.value; break; } case ComponentTypes.Explorer: { const versionField: VersionField = {value: this.configuration.versions.explorerChart}; callback(versionField); this.configuration.versions.explorerChart = versionField.value; break; } case ComponentTypes.RelayNodes: { const versionField: VersionField = {value: this.configuration.versions.jsonRpcRelayChart}; callback(versionField); this.configuration.versions.jsonRpcRelayChart = versionField.value; break; } case ComponentTypes.BlockNode: { const versionField: VersionField = {value: this.configuration.versions.blockNodeChart}; callback(versionField); this.configuration.versions.blockNodeChart = versionField.value; break; } case ComponentTypes.Cli: { const versionField: VersionField = {value: this.configuration.versions.cli}; callback(versionField); this.configuration.versions.cli = versionField.value; break; } case ComponentTypes.Chart: { const versionField: VersionField = {value: this.configuration.versions.chart}; callback(versionField); this.configuration.versions.chart = versionField.value; break; } default: { throw new SoloError(`Unsupported component type: ${componentType}`); } } } public getComponentVersion(type: ComponentTypes): SemanticVersion<string> { let version: SemanticVersion<string>; const getVersionCallback: (versionField: VersionField) => void = (versionField: VersionField): void => { version = versionField.value; }; this.applyCallbackToVersionField(type, getVersionCallback); return version; } }