diff --git a/package-lock.json b/package-lock.json index 3510670bd..48951798c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "inquirer": "^12.1.0", "ip": "^2.0.1", "js-base64": "^3.7.7", - "js-yaml": "^4.1.0", "jsdoc": "^4.0.4", "listr2": "^8.2.5", "semver": "^7.6.3", @@ -68,6 +67,7 @@ "@types/stream-buffers": "^3.0.7", "@types/tar": "^6.1.13", "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.13", "@types/yargs": "^17.0.33", "@typescript-eslint/utils": "^8.15.0", "c8": "^10.1.2", @@ -2717,6 +2717,16 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", "integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==" }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", diff --git a/package.json b/package.json index 6a0ee7002..ac688a0fa 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "inquirer": "^12.1.0", "ip": "^2.0.1", "js-base64": "^3.7.7", - "js-yaml": "^4.1.0", "jsdoc": "^4.0.4", "listr2": "^8.2.5", "semver": "^7.6.3", @@ -96,6 +95,7 @@ "@types/stream-buffers": "^3.0.7", "@types/tar": "^6.1.13", "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.13", "@types/yargs": "^17.0.33", "@typescript-eslint/utils": "^8.15.0", "c8": "^10.1.2", diff --git a/src/commands/base.ts b/src/commands/base.ts index d4c9fe4fd..c0850ccc9 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -18,9 +18,17 @@ import paths from 'path'; import {MissingArgumentError} from '../core/errors.js'; import {ShellRunner} from '../core/shell_runner.js'; -import type {ChartManager, ConfigManager, Helm, K8, DependencyManager, LeaseManager} from '../core/index.js'; +import type { + ChartManager, + ConfigManager, + Helm, + K8, + DependencyManager, + LeaseManager, + RemoteConfigManager, + LocalConfig, +} from '../core/index.js'; import type {CommandFlag, Opts} from '../types/index.js'; -import {type LocalConfig} from '../core/config/local_config.js'; export class BaseCommand extends ShellRunner { protected readonly helm: Helm; @@ -31,6 +39,7 @@ export class BaseCommand extends ShellRunner { protected readonly leaseManager: LeaseManager; protected readonly _configMaps = new Map(); protected readonly localConfig: LocalConfig; + protected readonly remoteConfigManager: RemoteConfigManager; constructor(opts: Opts) { if (!opts || !opts.logger) throw new Error('An instance of core/SoloLogger is required'); @@ -40,6 +49,8 @@ export class BaseCommand extends ShellRunner { if (!opts || !opts.configManager) throw new Error('An instance of core/ConfigManager is required'); if (!opts || !opts.depManager) throw new Error('An instance of core/DependencyManager is required'); if (!opts || !opts.localConfig) throw new Error('An instance of core/LocalConfig is required'); + if (!opts || !opts.remoteConfigManager) + throw new Error('An instance of core/config/RemoteConfigManager is required'); super(opts.logger); @@ -50,6 +61,7 @@ export class BaseCommand extends ShellRunner { this.depManager = opts.depManager; this.leaseManager = opts.leaseManager; this.localConfig = opts.localConfig; + this.remoteConfigManager = opts.remoteConfigManager; } async prepareChartPath(chartDir: string, chartRepo: string, chartReleaseName: string) { diff --git a/src/commands/context/handlers.ts b/src/commands/context/handlers.ts index cd391c645..52a236c8f 100644 --- a/src/commands/context/handlers.ts +++ b/src/commands/context/handlers.ts @@ -36,7 +36,7 @@ export class ContextCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv), - this.parent.getLocalConfig().promptLocalConfigTask(this.parent.getK8(), argv), + this.parent.getLocalConfig().promptLocalConfigTask(), this.tasks.updateLocalConfig(argv), ], { diff --git a/src/commands/context/tasks.ts b/src/commands/context/tasks.ts index c481d80bf..393e25976 100644 --- a/src/commands/context/tasks.ts +++ b/src/commands/context/tasks.ts @@ -35,14 +35,14 @@ export class ContextCommandTasks { const isQuiet = !!argv[flags.quiet.name]; let currentDeploymentName = argv[flags.namespace.name]; - let clusterAliases = Templates.parseClusterAliases(argv[flags.clusterName.name]); + let clusters = Templates.parseClusterAliases(argv[flags.clusterName.name]); let contextName = argv[flags.context.name]; const kubeContexts = await this.parent.getK8().getContexts(); if (isQuiet) { const currentCluster = await this.parent.getK8().getKubeConfig().getCurrentCluster(); - if (!clusterAliases.length) clusterAliases = [currentCluster.name]; + if (!clusters.length) clusters = [currentCluster.name]; if (!contextName) contextName = await this.parent.getK8().getKubeConfig().getCurrentContext(); if (!currentDeploymentName) { @@ -50,10 +50,10 @@ export class ContextCommandTasks { currentDeploymentName = selectedContext && selectedContext.namespace ? selectedContext.namespace : 'default'; } } else { - if (!clusterAliases.length) { + if (!clusters.length) { const prompt = this.promptMap.get(flags.clusterName.name); - const unparsedClusterAliases = await prompt(task, clusterAliases); - clusterAliases = Templates.parseClusterAliases(unparsedClusterAliases); + const unparsedClusterAliases = await prompt(task, clusters); + clusters = Templates.parseClusterAliases(unparsedClusterAliases); } if (!contextName) { const prompt = this.promptMap.get(flags.context.name); @@ -74,13 +74,13 @@ export class ContextCommandTasks { // Set clusters for active deployment const deployments = this.parent.getLocalConfig().deployments; - deployments[currentDeploymentName].clusterAliases = clusterAliases; + deployments[currentDeploymentName].clusters = clusters; this.parent.getLocalConfig().setDeployments(deployments); this.parent.getK8().getKubeConfig().setCurrentContext(contextName); this.parent.logger.info( - `Save LocalConfig file: [currentDeploymentName: ${currentDeploymentName}, contextName: ${contextName}, clusterAliases: ${clusterAliases.join(' ')}]`, + `Save LocalConfig file: [currentDeploymentName: ${currentDeploymentName}, contextName: ${contextName}, clusters: ${clusters.join(' ')}]`, ); await this.parent.getLocalConfig().write(); }); diff --git a/src/commands/deployment.ts b/src/commands/deployment.ts new file mode 100644 index 000000000..a487f95fa --- /dev/null +++ b/src/commands/deployment.ts @@ -0,0 +1,157 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {Listr, type ListrTaskWrapper} from 'listr2'; +import {SoloError} from '../core/errors.js'; +import {BaseCommand} from './base.js'; +import * as flags from './flags.js'; +import {constants, Templates} from '../core/index.js'; +import * as prompts from './prompts.js'; +import chalk from 'chalk'; +import {RemoteConfigTasks} from '../core/config/remote/remote_config_tasks.js'; +import {ListrLease} from '../core/lease/listr_lease.js'; +import type {Namespace} from '../core/config/remote/types.js'; +import type {CommandFlag, ContextClusterStructure} from '../types/index.js'; + +export class DeploymentCommand extends BaseCommand { + private static get DEPLOY_FLAGS_LIST(): CommandFlag[] { + return [ + flags.quiet, + flags.namespace, + flags.userEmailAddress, + flags.deploymentClusters, + flags.contextClusterUnparsed, + ]; + } + + private async create(argv: any): Promise { + const self = this; + const lease = await self.leaseManager.create(); + + interface Config { + namespace: Namespace; + contextClusterUnparsed: string; + contextCluster: ContextClusterStructure; + } + interface Context { + config: Config; + } + + const tasks = new Listr( + [ + { + title: 'Initialize', + task: async (ctx, task): Promise> => { + self.configManager.update(argv); + self.logger.debug('Loaded cached config', {config: self.configManager.config}); + + await prompts.execute(task, self.configManager, DeploymentCommand.DEPLOY_FLAGS_LIST); + + ctx.config = { + contextClusterUnparsed: self.configManager.getFlag(flags.contextClusterUnparsed), + namespace: self.configManager.getFlag(flags.namespace), + } as Config; + + ctx.config.contextCluster = Templates.parseContextCluster(ctx.config.contextClusterUnparsed); + + const namespace = ctx.config.namespace; + + if (!(await self.k8.hasNamespace(namespace))) { + await self.k8.createNamespace(namespace); + } + + self.logger.debug('Prepared config', {config: ctx.config, cachedConfig: self.configManager.config}); + + return ListrLease.newAcquireLeaseTask(lease, task); + }, + }, + this.localConfig.promptLocalConfigTask(), + { + title: 'Validate cluster connections', + task: async (ctx, task): Promise> => { + const subTasks = []; + + for (const cluster of Object.keys(ctx.config.contextCluster)) { + subTasks.push({ + title: `Testing connection to cluster: ${chalk.cyan(cluster)}`, + task: async (_: Context, task: ListrTaskWrapper) => { + if (!(await self.k8.testClusterConnection(cluster))) { + task.title = `${task.title} - ${chalk.red('Cluster connection failed')}`; + + throw new SoloError(`Cluster connection failed for: ${cluster}`); + } + }, + }); + } + + return task.newListr(subTasks, { + concurrent: true, + rendererOptions: {collapseSubtasks: false}, + }); + }, + }, + RemoteConfigTasks.createRemoteConfig.bind(this)(), + ], + { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION, + }, + ); + + try { + await tasks.run(); + } catch (e: Error | any) { + throw new SoloError(`Error installing chart ${constants.SOLO_DEPLOYMENT_CHART}`, e); + } finally { + await lease.release(); + } + + return true; + } + + public getCommandDefinition(): {command: string; desc: string; builder: Function} { + const self = this; + return { + command: 'deployment', + desc: 'Manage solo network deployment', + builder: (yargs: any): any => { + return yargs + .command({ + command: 'create', + desc: 'Creates solo deployment', + builder: (y: any) => flags.setCommandFlags(y, ...DeploymentCommand.DEPLOY_FLAGS_LIST), + handler: (argv: any) => { + self.logger.debug("==== Running 'deployment create' ==="); + self.logger.debug(argv); + + self + .create(argv) + .then(r => { + self.logger.debug('==== Finished running `deployment create`===='); + + if (!r) process.exit(1); + }) + .catch(err => { + self.logger.showUserError(err); + process.exit(1); + }); + }, + }) + .demandCommand(1, 'Select a chart command'); + }, + }; + } +} diff --git a/src/commands/flags.ts b/src/commands/flags.ts index 1daffe18d..a2d9cf470 100644 --- a/src/commands/flags.ts +++ b/src/commands/flags.ts @@ -745,7 +745,6 @@ export const userEmailAddress: CommandFlag = { name: 'email', definition: { describe: 'User email address used for local configuration', - defaultValue: '', type: 'string', }, }; @@ -760,22 +759,11 @@ export const context: CommandFlag = { }, }; -export const deploymentName: CommandFlag = { - constName: 'deploymentName', - name: 'deployment-name', - definition: { - describe: 'Solo deployment name', - defaultValue: '', - type: 'string', - }, -}; - export const deploymentClusters: CommandFlag = { constName: 'deploymentClusters', name: 'deployment-clusters', definition: { describe: 'Solo deployment cluster list (comma separated)', - defaultValue: '', type: 'string', }, }; @@ -855,6 +843,17 @@ export const stakeAmounts: CommandFlag = { }, }; +export const contextClusterUnparsed: CommandFlag = { + constName: 'contextClusterUnparsed', + name: 'context-cluster', + definition: { + describe: + 'Context cluster mapping where context is key = value is cluster and comma delimited if more than one, ' + + '(e.g.: --context-cluster kind-solo=kind-solo,kind-solo-2=kind-solo-2)', + type: 'string', + }, +}; + export const allFlags: CommandFlag[] = [ accountId, amount, @@ -869,6 +868,7 @@ export const allFlags: CommandFlag[] = [ chartDirectory, clusterName, clusterSetupNamespace, + context, deletePvcs, deleteSecrets, deployCertManager, @@ -876,7 +876,6 @@ export const allFlags: CommandFlag[] = [ deployHederaExplorer, deployJsonRpcRelay, deploymentClusters, - deploymentName, deployMinio, deployPrometheusStack, devMode, @@ -931,6 +930,7 @@ export const allFlags: CommandFlag[] = [ grpcWebTlsCertificatePath, grpcTlsKeyPath, grpcWebTlsKeyPath, + contextClusterUnparsed, ]; /** Resets the definition.disablePrompt for all flags */ diff --git a/src/commands/index.ts b/src/commands/index.ts index 15dd5e4e4..142723a58 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -23,6 +23,7 @@ import {NetworkCommand} from './network.js'; import {NodeCommand} from './node/index.js'; import {RelayCommand} from './relay.js'; import {AccountCommand} from './account.js'; +import {DeploymentCommand} from './deployment.js'; import {type Opts} from '../types/index.js'; /** @@ -39,16 +40,19 @@ function Initialize(opts: Opts) { const relayCmd = new RelayCommand(opts); const accountCmd = new AccountCommand(opts); const mirrorNodeCmd = new MirrorNodeCommand(opts); + const deploymentCommand = new DeploymentCommand(opts); return [ initCmd.getCommandDefinition(), clusterCmd.getCommandDefinition(), contextCmd.getCommandDefinition(), + contextCmd.getCommandDefinition(), networkCommand.getCommandDefinition(), nodeCmd.getCommandDefinition(), relayCmd.getCommandDefinition(), accountCmd.getCommandDefinition(), mirrorNodeCmd.getCommandDefinition(), + deploymentCommand.getCommandDefinition(), ]; } diff --git a/src/commands/mirror_node.ts b/src/commands/mirror_node.ts index de3be639b..5a2a82e97 100644 --- a/src/commands/mirror_node.ts +++ b/src/commands/mirror_node.ts @@ -22,8 +22,9 @@ import {BaseCommand} from './base.js'; import * as flags from './flags.js'; import * as prompts from './prompts.js'; import {getFileContents, getEnvValue} from '../core/helpers.js'; -import {type PodName} from '../types/aliases.js'; -import {type Opts} from '../types/index.js'; +import {RemoteConfigTasks} from '../core/config/remote/remote_config_tasks.js'; +import type {PodName} from '../types/aliases.js'; +import type {Opts} from '../types/index.js'; import {ListrLease} from '../core/lease/listr_lease.js'; export class MirrorNodeCommand extends BaseCommand { @@ -232,6 +233,7 @@ export class MirrorNodeCommand extends BaseCommand { return ListrLease.newAcquireLeaseTask(lease, task); }, }, + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), { title: 'Enable mirror-node', task: (_, parentTask) => { @@ -418,6 +420,7 @@ export class MirrorNodeCommand extends BaseCommand { ); }, }, + RemoteConfigTasks.addMirrorNodeAndMirrorNodeToExplorer.bind(this)(), ], { concurrent: false, @@ -488,6 +491,7 @@ export class MirrorNodeCommand extends BaseCommand { return ListrLease.newAcquireLeaseTask(lease, task); }, }, + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), { title: 'Destroy mirror-node', task: async ctx => { @@ -513,6 +517,7 @@ export class MirrorNodeCommand extends BaseCommand { }, skip: ctx => !ctx.config.isChartInstalled, }, + RemoteConfigTasks.removeMirrorNodeAndMirrorNodeToExplorer.bind(this)(), ], { concurrent: false, diff --git a/src/commands/network.ts b/src/commands/network.ts index 7ae5f324d..bd3fbfdc5 100644 --- a/src/commands/network.ts +++ b/src/commands/network.ts @@ -26,6 +26,7 @@ import * as helpers from '../core/helpers.js'; import path from 'path'; import {addDebugOptions, validatePath} from '../core/helpers.js'; import fs from 'fs'; +import {RemoteConfigTasks} from '../core/config/remote/remote_config_tasks.js'; import type {CertificateManager, KeyManager, PlatformInstaller, ProfileManager} from '../core/index.js'; import type {NodeAlias, NodeAliases} from '../types/aliases.js'; import type {Opts} from '../types/index.js'; @@ -286,6 +287,7 @@ export class NetworkCommand extends BaseCommand { return ListrLease.newAcquireLeaseTask(lease, task); }, }, + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), { title: 'Copy gRPC TLS Certificates', task: (ctx, parentTask) => @@ -470,6 +472,7 @@ export class NetworkCommand extends BaseCommand { }); }, }, + RemoteConfigTasks.addNodesAndProxies.bind(this)(), ], { concurrent: false, diff --git a/src/commands/node/configs.ts b/src/commands/node/configs.ts index b71c7ade8..6bc513207 100644 --- a/src/commands/node/configs.ts +++ b/src/commands/node/configs.ts @@ -457,7 +457,7 @@ export interface NodeUpdateConfigClass { allNodeAliases: NodeAliases; chartPath: string; existingNodeAliases: NodeAliases; - freezeAdminPrivateKey: PrivateKey; + freezeAdminPrivateKey: PrivateKey | string; keysDir: string; nodeClient: any; podNames: Record; diff --git a/src/commands/node/handlers.ts b/src/commands/node/handlers.ts index 17b17844f..a70f5e8f4 100644 --- a/src/commands/node/handlers.ts +++ b/src/commands/node/handlers.ts @@ -38,14 +38,17 @@ import { type PlatformInstaller, type AccountManager, type LeaseManager, + type RemoteConfigManager, } from '../../core/index.js'; import {IllegalArgumentError} from '../../core/errors.js'; +import {ConsensusNodeStates} from '../../core/config/remote/enumerations.js'; +import {RemoteConfigTasks} from '../../core/config/remote/remote_config_tasks.js'; import type {SoloLogger} from '../../core/logging.js'; import type {NodeCommand} from './index.js'; import type {NodeCommandTasks} from './tasks.js'; import {type Lease} from '../../core/lease/lease.js'; -import {type CommandHandlers} from '../../types/index.js'; import {NodeSubcommandType} from '../../core/enumerations.js'; +import {type CommandHandlers} from '../../types/index.js'; export class NodeCommandHandlers implements CommandHandlers { private readonly accountManager: AccountManager; @@ -55,6 +58,7 @@ export class NodeCommandHandlers implements CommandHandlers { private readonly k8: K8; private readonly tasks: NodeCommandTasks; private readonly leaseManager: LeaseManager; + public readonly remoteConfigManager: RemoteConfigManager; private getConfig: any; private prepareChartPath: any; @@ -78,6 +82,7 @@ export class NodeCommandHandlers implements CommandHandlers { this.k8 = opts.k8; this.platformInstaller = opts.platformInstaller; this.leaseManager = opts.leaseManager; + this.remoteConfigManager = opts.remoteConfigManager; this.getConfig = opts.parent.getConfig.bind(opts.parent); this.prepareChartPath = opts.parent.prepareChartPath.bind(opts.parent); @@ -104,6 +109,8 @@ export class NodeCommandHandlers implements CommandHandlers { deletePrepareTaskList(argv: any, lease: Lease) { return [ this.tasks.initialize(argv, deleteConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateSingleNodeState.bind(this)({excludedStates: []}), this.tasks.identifyExistingNodes(), this.tasks.loadAdminKey(), this.tasks.prepareUpgradeZip(), @@ -146,6 +153,8 @@ export class NodeCommandHandlers implements CommandHandlers { addPrepareTasks(argv: any, lease: Lease) { return [ this.tasks.initialize(argv, addConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateSingleNodeState.bind(this)({excludedStates: []}), this.tasks.checkPVCsEnabled(), this.tasks.identifyExistingNodes(), this.tasks.determineNewNodeAccountNumber(), @@ -197,6 +206,8 @@ export class NodeCommandHandlers implements CommandHandlers { updatePrepareTasks(argv, lease: Lease) { return [ this.tasks.initialize(argv, updateConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateSingleNodeState.bind(this)({excludedStates: []}), this.tasks.identifyExistingNodes(), this.tasks.loadAdminKey(), this.tasks.prepareUpgradeZip(), @@ -247,6 +258,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, prepareUpgradeConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.prepareUpgradeZip(), this.tasks.sendPrepareUpgradeTransaction(), ], @@ -268,6 +280,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, prepareUpgradeConfigBuilder.bind(this), null), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.prepareUpgradeZip(), this.tasks.sendFreezeUpgradeTransaction(), ], @@ -291,6 +304,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, downloadGeneratedFilesConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.identifyExistingNodes(), this.tasks.downloadNodeGeneratedFiles(), ], @@ -356,6 +370,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, updateConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.loadContextData(argv, NodeCommandHandlers.UPDATE_CONTEXT_FILE, helpers.updateLoadContextParser), ...this.updateSubmitTransactionsTasks(argv), ], @@ -377,6 +392,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, updateConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.loadContextData(argv, NodeCommandHandlers.UPDATE_CONTEXT_FILE, helpers.updateLoadContextParser), ...this.updateExecuteTasks(argv), ], @@ -530,6 +546,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, addConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.loadContextData(argv, NodeCommandHandlers.ADD_CONTEXT_FILE, helpers.addLoadContextParser), ...this.addSubmitTransactionsTasks(argv), ], @@ -553,6 +570,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, addConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.identifyExistingNodes(), this.tasks.loadContextData(argv, NodeCommandHandlers.ADD_CONTEXT_FILE, helpers.addLoadContextParser), ...this.addExecuteTasks(argv), @@ -572,7 +590,11 @@ export class NodeCommandHandlers implements CommandHandlers { async logs(argv: any) { argv = helpers.addFlagsToArgv(argv, NodeFlags.LOGS_FLAGS); const action = helpers.commandActionBuilder( - [this.tasks.initialize(argv, logsConfigBuilder.bind(this), null), this.tasks.getNodeLogsAndConfigs()], + [ + this.tasks.initialize(argv, logsConfigBuilder.bind(this), null), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + this.tasks.getNodeLogsAndConfigs(), + ], { concurrent: false, rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION, @@ -610,6 +632,10 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, refreshConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateAllNodeStates.bind(this)({ + acceptedStates: [ConsensusNodeStates.STARTED, ConsensusNodeStates.SETUP, ConsensusNodeStates.INITIALIZED], + }), this.tasks.identifyNetworkPods(), this.tasks.dumpNetworkNodesSaveState(), this.tasks.fetchPlatformSoftware('nodeAliases'), @@ -636,6 +662,7 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, keysConfigBuilder.bind(this), null), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.generateGossipKeys(), this.tasks.generateGrpcTlsKeys(), this.tasks.finalize(), @@ -660,8 +687,13 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, stopConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateAllNodeStates.bind(this)({ + acceptedStates: [ConsensusNodeStates.STARTED, ConsensusNodeStates.SETUP], + }), this.tasks.identifyNetworkPods(), this.tasks.stopNodes(), + RemoteConfigTasks.changeAllNodeStates.bind(this)(ConsensusNodeStates.INITIALIZED), ], { concurrent: false, @@ -683,12 +715,15 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, startConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateAllNodeStates.bind(this)({acceptedStates: [ConsensusNodeStates.SETUP]}), this.tasks.identifyExistingNodes(), this.tasks.uploadStateFiles((ctx: any) => ctx.config.stateFile.length === 0), this.tasks.startNodes('nodeAliases'), this.tasks.enablePortForwarding(), this.tasks.checkAllNodesAreActive('nodeAliases'), this.tasks.checkNodeProxiesAreActive(), + RemoteConfigTasks.changeAllNodeStates.bind(this)(ConsensusNodeStates.STARTED), this.tasks.addNodeStakes(), ], { @@ -711,9 +746,14 @@ export class NodeCommandHandlers implements CommandHandlers { const action = helpers.commandActionBuilder( [ this.tasks.initialize(argv, setupConfigBuilder.bind(this), lease), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + RemoteConfigTasks.validateAllNodeStates.bind(this)({ + acceptedStates: [ConsensusNodeStates.INITIALIZED], + }), this.tasks.identifyNetworkPods(), this.tasks.fetchPlatformSoftware('nodeAliases'), this.tasks.setupNetworkNodes('nodeAliases'), + RemoteConfigTasks.changeAllNodeStates.bind(this)(ConsensusNodeStates.SETUP), ], { concurrent: false, diff --git a/src/commands/node/index.ts b/src/commands/node/index.ts index 5437e6c57..c7bac3d9f 100644 --- a/src/commands/node/index.ts +++ b/src/commands/node/index.ts @@ -74,6 +74,7 @@ export class NodeCommand extends BaseCommand { tasks: this.tasks, parent: this, leaseManager: opts.leaseManager, + remoteConfigManager: opts.remoteConfigManager, }); } diff --git a/src/commands/node/tasks.ts b/src/commands/node/tasks.ts index a1a5e8b83..293530972 100644 --- a/src/commands/node/tasks.ts +++ b/src/commands/node/tasks.ts @@ -15,16 +15,16 @@ * */ import { - type ChartManager, - type ConfigManager, constants, + Task, + Templates, + Zippy, type K8, + type ChartManager, + type ConfigManager, type KeyManager, type PlatformInstaller, type ProfileManager, - Task, - Templates, - Zippy, type AccountManager, type CertificateManager, } from '../../core/index.js'; @@ -74,8 +74,12 @@ import {type NodeAlias, type NodeAliases, type PodName} from '../../types/aliase import {NodeStatusCodes, NodeStatusEnums, NodeSubcommandType} from '../../core/enumerations.js'; import * as x509 from '@peculiar/x509'; import {type NodeCommand} from './index.js'; -import type {NodeDeleteConfigClass, NodeRefreshConfigClass, NodeUpdateConfigClass} from './configs.js'; -import type {NodeAddConfigClass} from './configs.js'; +import type { + NodeAddConfigClass, + NodeDeleteConfigClass, + NodeRefreshConfigClass, + NodeUpdateConfigClass, +} from './configs.js'; import {type Lease} from '../../core/lease/lease.js'; import {ListrLease} from '../../core/lease/listr_lease.js'; @@ -673,6 +677,7 @@ export class NodeCommandTasks { 'Download generated files from an existing node', async (ctx: any, task: ListrTaskWrapper) => { const config = ctx.config; + // don't try to download from the same node we are deleting, it won't work const nodeAlias = ctx.config.nodeAlias === config.existingNodeAliases[0] diff --git a/src/commands/prompts.ts b/src/commands/prompts.ts index d014a0444..1fa5cebc2 100644 --- a/src/commands/prompts.ts +++ b/src/commands/prompts.ts @@ -20,7 +20,7 @@ import {SoloError, IllegalArgumentError} from '../core/errors.js'; import {ConfigManager, constants} from '../core/index.js'; import * as flags from './flags.js'; import * as helpers from '../core/helpers.js'; -import {hederaExplorerVersion, resetDisabledPrompts} from './flags.js'; +import {resetDisabledPrompts} from './flags.js'; import type {ListrTaskWrapper} from 'listr2'; import {type CommandFlag} from '../types/index.js'; import validator from 'validator'; @@ -469,6 +469,10 @@ export async function promptUpdateAccountKeys(task: ListrTaskWrapper, input: any) { + if (input?.length) { + return input; + } + const promptForInput = async () => { return await task.prompt(ListrEnquirerPromptAdapter).run({ type: 'text', @@ -648,6 +652,17 @@ export async function promptOutputDir(task: ListrTaskWrapper, inp ); } +export async function promptContextCluster(task: ListrTaskWrapper, input: any) { + return await promptText( + task, + input, + null, + 'Enter context cluster mapping: ', + 'context-cluster cannot be empty', + flags.contextClusterUnparsed.name, + ); +} + //! ------------- Node Proxy Certificates ------------- !// export async function promptGrpcTlsCertificatePath(task: ListrTaskWrapper, input: any) { @@ -742,6 +757,9 @@ export function getPromptMap(): Map { .set(flags.hederaExplorerVersion, promptHederaExplorerVersion) .set(flags.inputDir.name, promptInputDir) .set(flags.outputDir.name, promptOutputDir) + .set(flags.contextClusterUnparsed.name, promptContextCluster) + .set(flags.context.name, promptContext) + .set(flags.deploymentClusters.name, promptDeploymentClusters) //! Node Proxy Certificates .set(flags.grpcTlsCertificatePath.name, promptGrpcTlsCertificatePath) @@ -776,7 +794,7 @@ export async function execute( throw new SoloError(`No prompt available for flag: ${flag.name}`); } - const prompt = prompts.get(flag.name) as Function; + const prompt = prompts.get(flag.name) as (task: ListrTaskWrapper, input: any) => Promise; if (configManager.getFlag(flags.quiet)) { return; } diff --git a/src/commands/relay.ts b/src/commands/relay.ts index c22845e54..0cfc48cbe 100644 --- a/src/commands/relay.ts +++ b/src/commands/relay.ts @@ -23,9 +23,10 @@ import {BaseCommand} from './base.js'; import * as flags from './flags.js'; import * as prompts from './prompts.js'; import {getNodeAccountMap} from '../core/helpers.js'; -import {type NodeAliases} from '../types/aliases.js'; -import {type Opts} from '../types/index.js'; +import {RemoteConfigTasks} from '../core/config/remote/remote_config_tasks.js'; import {ListrLease} from '../core/lease/listr_lease.js'; +import type {NodeAliases} from '../types/aliases.js'; +import type {Opts} from '../types/index.js'; export class RelayCommand extends BaseCommand { private readonly profileManager: ProfileManager; @@ -218,6 +219,7 @@ export class RelayCommand extends BaseCommand { return ListrLease.newAcquireLeaseTask(lease, task); }, }, + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), { title: 'Prepare chart values', task: async ctx => { @@ -280,6 +282,7 @@ export class RelayCommand extends BaseCommand { } }, }, + RemoteConfigTasks.addRelayComponent.bind(this)(), ], { concurrent: false, @@ -345,6 +348,7 @@ export class RelayCommand extends BaseCommand { return ListrLease.newAcquireLeaseTask(lease, task); }, }, + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), { title: 'Destroy JSON RPC Relay', task: async ctx => { @@ -359,6 +363,7 @@ export class RelayCommand extends BaseCommand { }, skip: ctx => !ctx.config.isChartInstalled, }, + RemoteConfigTasks.removeRelayComponent.bind(this)(), ], { concurrent: false, diff --git a/src/core/config/local_config.ts b/src/core/config/local_config.ts index 2ed810a9a..1022328fe 100644 --- a/src/core/config/local_config.ts +++ b/src/core/config/local_config.ts @@ -15,15 +15,17 @@ * */ import {IsEmail, IsNotEmpty, IsObject, IsString, validateSync} from 'class-validator'; +import type {ListrTask, ListrTaskWrapper} from 'listr2'; import fs from 'fs'; import * as yaml from 'yaml'; import {flags} from '../../commands/index.js'; -import {type Deployment, type Deployments, type LocalConfigData} from './local_config_data.js'; +import {type Deployments, DeploymentStructure, type LocalConfigData} from './local_config_data.js'; import {MissingArgumentError, SoloError} from '../errors.js'; -import {promptDeploymentClusters, promptNamespace, promptUserEmailAddress} from '../../commands/prompts.js'; +import {promptDeploymentClusters, promptUserEmailAddress} from '../../commands/prompts.js'; import {type SoloLogger} from '../logging.js'; -import {Task} from '../task.js'; import {IsDeployments} from '../validator_decorators.js'; +import type {ConfigManager} from '../config_manager.js'; +import type {EmailAddress, Namespace} from './remote/types.js'; import {Templates} from '../templates.js'; import {ErrorMessages} from '../error_messages.js'; @@ -34,7 +36,7 @@ export class LocalConfig implements LocalConfigData { message: ErrorMessages.LOCAL_CONFIG_INVALID_EMAIL, }, ) - userEmailAddress: string; + userEmailAddress: EmailAddress; // The string is the name of the deployment, will be used as the namespace, // so it needs to be available in all targeted clusters @@ -45,7 +47,7 @@ export class LocalConfig implements LocalConfigData { @IsObject({ message: ErrorMessages.LOCAL_CONFIG_INVALID_DEPLOYMENTS_FORMAT, }) - deployments: Deployments; + public deployments: Deployments; @IsString({ message: ErrorMessages.LOCAL_CONFIG_CURRENT_DEPLOYMENT_DOES_NOT_EXIST, @@ -55,9 +57,10 @@ export class LocalConfig implements LocalConfigData { private readonly skipPromptTask: boolean = false; - constructor( + public constructor( private readonly filePath: string, private readonly logger: SoloLogger, + private readonly configManager: ConfigManager, ) { if (!filePath || filePath === '') throw new MissingArgumentError('a valid filePath is required'); if (!logger) throw new Error('An instance of core/SoloLogger is required'); @@ -79,7 +82,7 @@ export class LocalConfig implements LocalConfigData { } } - private validate() { + private validate(): void { const errors = validateSync(this, {}); if (errors.length) { @@ -98,7 +101,7 @@ export class LocalConfig implements LocalConfigData { } } - public setUserEmailAddress(emailAddress: string): this { + public setUserEmailAddress(emailAddress: EmailAddress): this { this.userEmailAddress = emailAddress; this.validate(); return this; @@ -110,17 +113,17 @@ export class LocalConfig implements LocalConfigData { return this; } - public setCurrentDeployment(deploymentName: string): this { + public setCurrentDeployment(deploymentName: Namespace): this { this.currentDeploymentName = deploymentName; this.validate(); return this; } - public getCurrentDeployment(): Deployment { + public getCurrentDeployment(): DeploymentStructure { return this.deployments[this.currentDeploymentName]; } - private configFileExists(): boolean { + public configFileExists(): boolean { return fs.existsSync(this.filePath); } @@ -134,34 +137,32 @@ export class LocalConfig implements LocalConfigData { this.logger.info(`Wrote local config to ${this.filePath}`); } - public promptLocalConfigTask(k8, argv): Task { + public promptLocalConfigTask(): ListrTask { const self = this; - return new Task( - 'Prompt local configuration', - async (ctx, task) => { - let userEmailAddress = argv[flags.userEmailAddress.name]; + + return { + title: 'Prompt local configuration', + skip: this.skipPromptTask, + task: async (_: any, task: ListrTaskWrapper): Promise => { + let userEmailAddress = self.configManager.getFlag(flags.userEmailAddress); if (!userEmailAddress) userEmailAddress = await promptUserEmailAddress(task, userEmailAddress); - let deploymentName = argv[flags.namespace.name]; - if (!deploymentName) deploymentName = await promptNamespace(task, deploymentName); + const deploymentName = self.configManager.getFlag(flags.namespace); + if (!deploymentName) throw new SoloError('Namespace was not specified'); - let deploymentClusters = argv[flags.deploymentClusters.name]; + let deploymentClusters = self.configManager.getFlag(flags.deploymentClusters); if (!deploymentClusters) deploymentClusters = await promptDeploymentClusters(task, deploymentClusters); - const deployments = {}; - deployments[deploymentName] = { - clusterAliases: Templates.parseClusterAliases(deploymentClusters), + const deployments: Deployments = { + [deploymentName]: {clusters: Templates.parseClusterAliases(deploymentClusters)}, }; - self.userEmailAddress = userEmailAddress; - self.deployments = deployments; - self.currentDeploymentName = deploymentName; - self.validate(); - await self.write(); - - return self; + this.userEmailAddress = userEmailAddress; + this.deployments = deployments; + this.currentDeploymentName = deploymentName; + this.validate(); + await this.write(); }, - self.skipPromptTask, - ) as Task; + }; } } diff --git a/src/core/config/local_config_data.ts b/src/core/config/local_config_data.ts index eb50c55fe..9ea49c518 100644 --- a/src/core/config/local_config_data.ts +++ b/src/core/config/local_config_data.ts @@ -14,17 +14,16 @@ * limitations under the License. * */ +import type {Cluster, EmailAddress, Namespace} from './remote/types.js'; -export interface Deployment { - clusterAliases: string[]; +export interface DeploymentStructure { + clusters: Cluster[]; } -// an alias for the cluster, provided during the configuration -// of the deployment, must be unique -export type Deployments = Record; +export type Deployments = Record; export interface LocalConfigData { - userEmailAddress: string; + userEmailAddress: EmailAddress; deployments: Deployments; - currentDeploymentName: string; + currentDeploymentName: Namespace; } diff --git a/src/core/config/remote/components/base_component.ts b/src/core/config/remote/components/base_component.ts new file mode 100644 index 000000000..2b70d7e76 --- /dev/null +++ b/src/core/config/remote/components/base_component.ts @@ -0,0 +1,78 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from '../enumerations.js'; +import {SoloError} from '../../../errors.js'; +import type {Cluster, Component, Namespace, ComponentName} from '../types.js'; +import type {ToObject, Validate} from '../../../../types/index.js'; + +/** + * Represents the base structure and common functionality for all components within the system. + * This class provides validation, comparison, and serialization functionality for components. + */ +export abstract class BaseComponent implements Component, Validate, ToObject { + /** + * @param type - type for identifying. + * @param name - the name to distinguish components. + * @param cluster - the cluster in which the component is deployed. + * @param namespace - the namespace associated with the component. + */ + protected constructor( + public readonly type: ComponentType, + public readonly name: ComponentName, + public readonly cluster: Cluster, + public readonly namespace: Namespace, + ) {} + + /* -------- Utilities -------- */ + + /** + * Compares two BaseComponent instances for equality. + * + * @param x - The first component to compare + * @param y - The second component to compare + * @returns boolean - true if the components are equal + */ + public static compare(x: BaseComponent, y: BaseComponent): boolean { + return x.name === y.name && x.type === y.type && x.cluster === y.cluster && x.namespace === y.namespace; + } + + public validate(): void { + if (!this.name || typeof this.name !== 'string') { + throw new SoloError(`Invalid name: ${this.name}`); + } + + if (!this.cluster || typeof this.cluster !== 'string') { + throw new SoloError(`Invalid cluster: ${this.cluster}`); + } + + if (!this.namespace || typeof this.namespace !== 'string') { + throw new SoloError(`Invalid namespace: ${this.namespace}`); + } + + if (!Object.values(ComponentType).includes(this.type)) { + throw new SoloError(`Invalid component type: ${this.type}`); + } + } + + public toObject(): Component { + return { + name: this.name, + cluster: this.cluster, + namespace: this.namespace, + }; + } +} diff --git a/src/core/config/remote/components/consensus_node_component.ts b/src/core/config/remote/components/consensus_node_component.ts new file mode 100644 index 000000000..c9c2639c8 --- /dev/null +++ b/src/core/config/remote/components/consensus_node_component.ts @@ -0,0 +1,72 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType, ConsensusNodeStates} from '../enumerations.js'; +import {BaseComponent} from './base_component.js'; +import {SoloError} from '../../../errors.js'; +import type {Cluster, IConsensusNodeComponent, Namespace, ComponentName} from '../types.js'; +import type {ToObject} from '../../../../types/index.js'; + +/** + * Represents a consensus node component within the system. + * + * A `ConsensusNodeComponent` extends the functionality of `BaseComponent` and includes additional properties and behaviors + * specific to consensus nodes, such as maintaining and validating the node's state. + */ +export class ConsensusNodeComponent + extends BaseComponent + implements IConsensusNodeComponent, ToObject +{ + /** + * @param name - the name to distinguish components. + * @param cluster - associated to component + * @param namespace - associated to component + * @param state - of the consensus node + */ + public constructor( + name: ComponentName, + cluster: Cluster, + namespace: Namespace, + public readonly state: ConsensusNodeStates, + ) { + super(ComponentType.ConsensusNode, name, cluster, namespace); + + this.validate(); + } + + /* -------- Utilities -------- */ + + /** Handles creating instance of the class from plain object. */ + public static fromObject(component: IConsensusNodeComponent): ConsensusNodeComponent { + const {name, cluster, namespace, state} = component; + return new ConsensusNodeComponent(name, cluster, namespace, state); + } + + public validate(): void { + super.validate(); + + if (!Object.values(ConsensusNodeStates).includes(this.state)) { + throw new SoloError(`Invalid consensus node state: ${this.state}`); + } + } + + public toObject(): IConsensusNodeComponent { + return { + ...super.toObject(), + state: this.state, + }; + } +} diff --git a/src/core/config/remote/components/envoy_proxy_component.ts b/src/core/config/remote/components/envoy_proxy_component.ts new file mode 100644 index 000000000..758711c10 --- /dev/null +++ b/src/core/config/remote/components/envoy_proxy_component.ts @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from '../enumerations.js'; +import {BaseComponent} from './base_component.js'; +import type {Component} from '../types.js'; + +export class EnvoyProxyComponent extends BaseComponent { + public constructor(name: string, cluster: string, namespace: string) { + super(ComponentType.EnvoyProxy, name, cluster, namespace); + this.validate(); + } + + /* -------- Utilities -------- */ + + /** Handles creating instance of the class from plain object. */ + public static fromObject(component: Component): EnvoyProxyComponent { + const {name, cluster, namespace} = component; + return new EnvoyProxyComponent(name, cluster, namespace); + } +} diff --git a/src/core/config/remote/components/ha_proxy_component.ts b/src/core/config/remote/components/ha_proxy_component.ts new file mode 100644 index 000000000..a4b4c8967 --- /dev/null +++ b/src/core/config/remote/components/ha_proxy_component.ts @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from '../enumerations.js'; +import {BaseComponent} from './base_component.js'; +import type {Component} from '../types.js'; + +export class HaProxyComponent extends BaseComponent { + public constructor(name: string, cluster: string, namespace: string) { + super(ComponentType.HaProxy, name, cluster, namespace); + this.validate(); + } + + /* -------- Utilities -------- */ + + /** Handles creating instance of the class from plain object. */ + public static fromObject(component: Component): HaProxyComponent { + const {name, cluster, namespace} = component; + return new HaProxyComponent(name, cluster, namespace); + } +} diff --git a/src/core/config/remote/components/index.ts b/src/core/config/remote/components/index.ts new file mode 100644 index 000000000..a2c8d1438 --- /dev/null +++ b/src/core/config/remote/components/index.ts @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {BaseComponent} from './base_component.js'; +import {ConsensusNodeComponent} from './consensus_node_component.js'; +import {HaProxyComponent} from './ha_proxy_component.js'; +import {EnvoyProxyComponent} from './envoy_proxy_component.js'; +import {MirrorNodeComponent} from './mirror_node_component.js'; +import {MirrorNodeExplorerComponent} from './mirror_node_explorer_component.js'; +import {RelayComponent} from './relay_component.js'; + +export { + BaseComponent, + ConsensusNodeComponent, + HaProxyComponent, + EnvoyProxyComponent, + MirrorNodeComponent, + MirrorNodeExplorerComponent, + RelayComponent, +}; diff --git a/src/core/config/remote/components/mirror_node_component.ts b/src/core/config/remote/components/mirror_node_component.ts new file mode 100644 index 000000000..071f68987 --- /dev/null +++ b/src/core/config/remote/components/mirror_node_component.ts @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from '../enumerations.js'; +import {BaseComponent} from './base_component.js'; +import type {Component} from '../types.js'; + +export class MirrorNodeComponent extends BaseComponent { + public constructor(name: string, cluster: string, namespace: string) { + super(ComponentType.MirrorNode, name, cluster, namespace); + this.validate(); + } + + /* -------- Utilities -------- */ + + /** Handles creating instance of the class from plain object. */ + public static fromObject(component: Component): MirrorNodeComponent { + const {name, cluster, namespace} = component; + return new MirrorNodeComponent(name, cluster, namespace); + } +} diff --git a/src/core/config/remote/components/mirror_node_explorer_component.ts b/src/core/config/remote/components/mirror_node_explorer_component.ts new file mode 100644 index 000000000..dc1c44ecc --- /dev/null +++ b/src/core/config/remote/components/mirror_node_explorer_component.ts @@ -0,0 +1,34 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from '../enumerations.js'; +import {BaseComponent} from './base_component.js'; +import type {Component} from '../types.js'; + +export class MirrorNodeExplorerComponent extends BaseComponent { + public constructor(name: string, cluster: string, namespace: string) { + super(ComponentType.MirrorNodeExplorer, name, cluster, namespace); + this.validate(); + } + + /* -------- Utilities -------- */ + + /** Handles creating instance of the class from plain object. */ + public static fromObject(component: Component): MirrorNodeExplorerComponent { + const {name, cluster, namespace} = component; + return new MirrorNodeExplorerComponent(name, cluster, namespace); + } +} diff --git a/src/core/config/remote/components/relay_component.ts b/src/core/config/remote/components/relay_component.ts new file mode 100644 index 000000000..578e00bf0 --- /dev/null +++ b/src/core/config/remote/components/relay_component.ts @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from '../enumerations.js'; +import {SoloError} from '../../../errors.js'; +import {BaseComponent} from './base_component.js'; +import type {IRelayComponent} from '../types.js'; +import type {NodeAliases} from '../../../../types/aliases.js'; +import type {ToObject} from '../../../../types/index.js'; + +export class RelayComponent extends BaseComponent implements IRelayComponent, ToObject { + /** + * @param name - to distinguish components. + * @param cluster - in which the component is deployed. + * @param namespace - associated with the component. + * @param consensusNodeAliases - list node aliases + */ + public constructor( + name: string, + cluster: string, + namespace: string, + public readonly consensusNodeAliases: NodeAliases = [], + ) { + super(ComponentType.Relay, name, cluster, namespace); + this.validate(); + } + + /* -------- Utilities -------- */ + + /** Handles creating instance of the class from plain object. */ + public static fromObject(component: IRelayComponent): RelayComponent { + const {name, cluster, namespace, consensusNodeAliases} = component; + return new RelayComponent(name, cluster, namespace, consensusNodeAliases); + } + + public validate(): void { + super.validate(); + + this.consensusNodeAliases.forEach(alias => { + if (!alias || typeof alias !== 'string') { + throw new SoloError(`Invalid consensus node alias: ${alias}, aliases ${this.consensusNodeAliases}`); + } + }); + } + + public toObject(): IRelayComponent { + return { + consensusNodeAliases: this.consensusNodeAliases, + ...super.toObject(), + }; + } +} diff --git a/src/core/config/remote/components_data_wrapper.ts b/src/core/config/remote/components_data_wrapper.ts new file mode 100644 index 000000000..36d3a4776 --- /dev/null +++ b/src/core/config/remote/components_data_wrapper.ts @@ -0,0 +1,306 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {ComponentType} from './enumerations.js'; +import {SoloError} from '../../errors.js'; +import { + BaseComponent, + ConsensusNodeComponent, + HaProxyComponent, + EnvoyProxyComponent, + MirrorNodeComponent, + MirrorNodeExplorerComponent, + RelayComponent, +} from './components/index.js'; +import type { + Component, + ComponentsDataStructure, + IConsensusNodeComponent, + IRelayComponent, + ComponentName, +} from './types.js'; +import type {ToObject, Validate} from '../../../types/index.js'; + +/** + * Represent the components in the remote config and handles: + * - CRUD operations on the components. + * - Validation. + * - Conversion FROM and TO plain object. + */ +export class ComponentsDataWrapper implements Validate, ToObject { + /** + * @param relays - Relay record mapping service name to relay components + * @param haProxies - HA Proxies record mapping service name to ha proxies components + * @param mirrorNodes - Mirror Nodes record mapping service name to mirror nodes components + * @param envoyProxies - Envoy Proxies record mapping service name to envoy proxies components + * @param consensusNodes - Consensus Nodes record mapping service name to consensus nodes components + * @param mirrorNodeExplorers - Mirror Node Explorers record mapping service name to mirror node explorers components + */ + private constructor( + private readonly relays: Record = {}, + private readonly haProxies: Record = {}, + private readonly mirrorNodes: Record = {}, + private readonly envoyProxies: Record = {}, + private readonly consensusNodes: Record = {}, + private readonly mirrorNodeExplorers: Record = {}, + ) { + this.validate(); + } + + /* -------- Modifiers -------- */ + + /** Used to add new component to their respective group. */ + public add(serviceName: ComponentName, component: BaseComponent): void { + const self = this; + + if (!serviceName || typeof serviceName !== 'string') { + throw new SoloError(`Service name is required ${serviceName}`); + } + + if (!(component instanceof BaseComponent)) { + throw new SoloError('Component must be instance of BaseComponent', null, BaseComponent); + } + + function addComponentCallback(components: Record): void { + if (self.exists(components, component)) { + throw new SoloError('Component exists', null, component.toObject()); + } + components[serviceName] = component; + } + + self.applyCallbackToComponentGroup(component.type, serviceName, addComponentCallback); + } + + /** Used to edit an existing component from their respective group. */ + public edit(serviceName: ComponentName, component: BaseComponent): void { + const self = this; + + if (!serviceName || typeof serviceName !== 'string') { + throw new SoloError(`Service name is required ${serviceName}`); + } + if (!(component instanceof BaseComponent)) { + throw new SoloError('Component must be instance of BaseComponent', null, BaseComponent); + } + + function editComponentCallback(components: Record): void { + if (!components[serviceName]) { + throw new SoloError(`Component doesn't exist, name: ${serviceName}`, null, {component}); + } + components[serviceName] = component; + } + + self.applyCallbackToComponentGroup(component.type, serviceName, editComponentCallback); + } + + /** Used to remove specific component from their respective group. */ + public remove(serviceName: ComponentName, type: ComponentType): void { + const self = this; + + if (!serviceName || typeof serviceName !== 'string') { + throw new SoloError(`Service name is required ${serviceName}`); + } + if (!Object.values(ComponentType).includes(type)) { + throw new SoloError(`Invalid component type ${type}`); + } + + function deleteComponentCallback(components: Record): void { + if (!components[serviceName]) { + throw new SoloError(`Component ${serviceName} of type ${type} not found while attempting to remove`); + } + delete components[serviceName]; + } + + self.applyCallbackToComponentGroup(type, serviceName, deleteComponentCallback); + } + + /* -------- Utilities -------- */ + + public getComponent(type: ComponentType, serviceName: ComponentName): T { + let component: T; + + function getComponentCallback(components: Record): void { + if (!components[serviceName]) { + throw new SoloError(`Component ${serviceName} of type ${type} not found while attempting to read`); + } + + component = components[serviceName] as T; + } + + this.applyCallbackToComponentGroup(type, serviceName, getComponentCallback); + + return component; + } + + /** + * Method used to map the type to the specific component group + * and pass it to a callback to apply modifications + */ + private applyCallbackToComponentGroup( + type: ComponentType, + serviceName: ComponentName, + callback: (components: Record) => void, + ): void { + switch (type) { + case ComponentType.Relay: + callback(this.relays); + break; + + case ComponentType.HaProxy: + callback(this.haProxies); + break; + case ComponentType.MirrorNode: + callback(this.mirrorNodes); + break; + case ComponentType.EnvoyProxy: + callback(this.envoyProxies); + break; + case ComponentType.ConsensusNode: + callback(this.consensusNodes); + break; + case ComponentType.MirrorNodeExplorer: + callback(this.mirrorNodeExplorers); + break; + default: + throw new SoloError(`Unknown component type ${type}, service name: ${serviceName}`); + } + + this.validate(); + } + + /** + * Handles creating instance of the class from plain object. + * + * @param components - component groups distinguished by their type. + */ + public static fromObject(components: ComponentsDataStructure): ComponentsDataWrapper { + const relays: Record = {}; + const haProxies: Record = {}; + const mirrorNodes: Record = {}; + const envoyProxies: Record = {}; + const consensusNodes: Record = {}; + const mirrorNodeExplorers: Record = {}; + + Object.entries(components).forEach( + ([type, components]: [ComponentType, Record]): void => { + switch (type) { + case ComponentType.Relay: + Object.entries(components).forEach(([name, component]: [ComponentName, IRelayComponent]): void => { + relays[name] = RelayComponent.fromObject(component); + }); + break; + + case ComponentType.HaProxy: + Object.entries(components).forEach(([name, component]: [ComponentName, Component]): void => { + haProxies[name] = HaProxyComponent.fromObject(component); + }); + break; + + case ComponentType.MirrorNode: + Object.entries(components).forEach(([name, component]: [ComponentName, Component]): void => { + mirrorNodes[name] = MirrorNodeComponent.fromObject(component); + }); + break; + + case ComponentType.EnvoyProxy: + Object.entries(components).forEach(([name, component]: [ComponentName, Component]): void => { + envoyProxies[name] = EnvoyProxyComponent.fromObject(component); + }); + break; + + case ComponentType.ConsensusNode: + Object.entries(components).forEach(([name, component]: [ComponentName, IConsensusNodeComponent]): void => { + consensusNodes[name] = ConsensusNodeComponent.fromObject(component); + }); + break; + + case ComponentType.MirrorNodeExplorer: + Object.entries(components).forEach(([name, component]: [ComponentName, Component]): void => { + mirrorNodeExplorers[name] = MirrorNodeExplorerComponent.fromObject(component); + }); + break; + + default: + throw new SoloError(`Unknown component type ${type}`); + } + }, + ); + + return new ComponentsDataWrapper(relays, haProxies, mirrorNodes, envoyProxies, consensusNodes, mirrorNodeExplorers); + } + + /** Used to create an empty instance used to keep the constructor private */ + public static initializeEmpty(): ComponentsDataWrapper { + return new ComponentsDataWrapper(); + } + + /** checks if component exists in the respective group */ + private exists(components: Record, newComponent: BaseComponent): boolean { + return Object.values(components).some(component => BaseComponent.compare(component, newComponent)); + } + + public validate(): void { + function testComponentsObject(components: Record, expectedInstance: any): void { + Object.entries(components).forEach(([name, component]: [ComponentName, BaseComponent]): void => { + if (!name || typeof name !== 'string') { + throw new SoloError(`Invalid component service name ${{[name]: component?.constructor?.name}}`); + } + + if (!(component instanceof expectedInstance)) { + throw new SoloError( + `Invalid component type, service name: ${name}, ` + + `expected ${expectedInstance?.name}, actual: ${component?.constructor?.name}`, + null, + {component}, + ); + } + }); + } + + testComponentsObject(this.relays, RelayComponent); + testComponentsObject(this.haProxies, HaProxyComponent); + testComponentsObject(this.mirrorNodes, MirrorNodeComponent); + testComponentsObject(this.envoyProxies, EnvoyProxyComponent); + testComponentsObject(this.consensusNodes, ConsensusNodeComponent); + testComponentsObject(this.mirrorNodeExplorers, MirrorNodeExplorerComponent); + } + + public toObject(): ComponentsDataStructure { + function transform(components: Record): Record { + const transformedComponents: Record = {}; + + Object.entries(components).forEach(([name, component]: [ComponentName, BaseComponent]): void => { + transformedComponents[name] = component.toObject() as Component; + }); + + return transformedComponents; + } + + return { + [ComponentType.Relay]: transform(this.relays), + [ComponentType.HaProxy]: transform(this.haProxies), + [ComponentType.MirrorNode]: transform(this.mirrorNodes), + [ComponentType.EnvoyProxy]: transform(this.envoyProxies), + [ComponentType.ConsensusNode]: transform(this.consensusNodes), + [ComponentType.MirrorNodeExplorer]: transform(this.mirrorNodeExplorers), + }; + } + + public clone(): ComponentsDataWrapper { + const data = this.toObject(); + + return ComponentsDataWrapper.fromObject(data); + } +} diff --git a/src/core/config/remote/enumerations.ts b/src/core/config/remote/enumerations.ts new file mode 100644 index 000000000..daf9e0a60 --- /dev/null +++ b/src/core/config/remote/enumerations.ts @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/** + * Enumerations that represent the component types used in remote config + * {@link ComponentsDataWrapper} + */ +export enum ComponentType { + ConsensusNode = 'consensusNodes', + HaProxy = 'haProxies', + EnvoyProxy = 'envoyProxies', + MirrorNode = 'mirrorNodes', + MirrorNodeExplorer = 'mirrorNodeExplorers', + Relay = 'replays', +} + +/** + * Enumerations that represent the state of consensus node in remote config + * {@link ConsensusNodeComponent} + */ +export enum ConsensusNodeStates { + INITIALIZED = 'initialized', + SETUP = 'setup', + STARTED = 'started', + FREEZED = 'freezed', + STOPPED = 'stopped', +} diff --git a/src/core/config/remote/metadata.ts b/src/core/config/remote/metadata.ts new file mode 100644 index 000000000..a6680babb --- /dev/null +++ b/src/core/config/remote/metadata.ts @@ -0,0 +1,120 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {Migration} from './migration.js'; +import {SoloError} from '../../errors.js'; +import * as k8s from '@kubernetes/client-node'; +import type {EmailAddress, Namespace, RemoteConfigMetadataStructure, Version} from './types.js'; +import type {Optional, ToObject, Validate} from '../../../types/index.js'; + +/** + * Represent the remote config metadata object and handles: + * - Validation + * - Reading + * - Making a migration + * - Converting from and to plain object + */ +export class RemoteConfigMetadata + implements RemoteConfigMetadataStructure, Validate, ToObject +{ + private readonly _name: Namespace; + private readonly _lastUpdatedAt: Date; + private readonly _lastUpdateBy: EmailAddress; + private _migration?: Migration; + + public constructor(name: Namespace, lastUpdatedAt: Date, lastUpdateBy: EmailAddress, migration?: Migration) { + this._name = name; + this._lastUpdatedAt = lastUpdatedAt; + this._lastUpdateBy = lastUpdateBy; + this._migration = migration; + this.validate(); + } + + /* -------- Modifiers -------- */ + + /** Simplifies making a migration */ + public makeMigration(email: EmailAddress, fromVersion: Version): void { + this._migration = new Migration(new Date(), email, fromVersion); + } + + /* -------- Getters -------- */ + + /** Retrieves the namespace */ + public get name(): Namespace { + return this._name; + } + + /** Retrieves the date when the remote config metadata was last updated */ + public get lastUpdatedAt(): Date { + return this._lastUpdatedAt; + } + + /** Retrieves the email of the user who last updated the remote config metadata */ + public get lastUpdateBy(): EmailAddress { + return this._lastUpdateBy; + } + + /** Retrieves the migration if such exists */ + public get migration(): Optional { + return this._migration; + } + + /* -------- Utilities -------- */ + + /** Handles conversion from plain object to instance */ + public static fromObject(metadata: RemoteConfigMetadataStructure): RemoteConfigMetadata { + let migration: Optional = undefined; + + if (metadata.migration) { + const { + migration: {migratedAt, migratedBy, fromVersion}, + } = metadata; + migration = new Migration(new Date(migratedAt), migratedBy, fromVersion); + } + + return new RemoteConfigMetadata(metadata.name, new Date(metadata.lastUpdatedAt), metadata.lastUpdateBy, migration); + } + + public validate(): void { + if (!this.name || typeof this.name !== 'string') { + throw new SoloError(`Invalid name: ${this.name}`); + } + + if (!(this.lastUpdatedAt instanceof Date)) { + throw new SoloError(`Invalid lastUpdatedAt: ${this.lastUpdatedAt}`); + } + + if (!this.lastUpdateBy || typeof this.lastUpdateBy !== 'string') { + throw new SoloError(`Invalid lastUpdateBy: ${this.lastUpdateBy}`); + } + + if (this.migration && !(this.migration instanceof Migration)) { + throw new SoloError(`Invalid migration: ${this.migration}`); + } + } + + public toObject(): RemoteConfigMetadataStructure { + const data = { + name: this.name, + lastUpdatedAt: new k8s.V1MicroTime(this.lastUpdatedAt), + lastUpdateBy: this.lastUpdateBy, + } as RemoteConfigMetadataStructure; + + if (this.migration) data.migration = this.migration.toObject() as any; + + return data; + } +} diff --git a/src/core/config/remote/migration.ts b/src/core/config/remote/migration.ts new file mode 100644 index 000000000..2ed7303f1 --- /dev/null +++ b/src/core/config/remote/migration.ts @@ -0,0 +1,67 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {SoloError} from '../../errors.js'; +import type {EmailAddress, IMigration, Version} from './types.js'; + +export class Migration implements IMigration { + private readonly _migratedAt: Date; + private readonly _migratedBy: EmailAddress; + private readonly _fromVersion: Version; + + public constructor(migratedAt: Date, migratedBy: EmailAddress, fromVersion: Version) { + this._migratedAt = migratedAt; + this._migratedBy = migratedBy; + this._fromVersion = fromVersion; + this.validate(); + } + + /* -------- Getters -------- */ + + public get migratedAt(): Date { + return this._migratedAt; + } + public get migratedBy(): EmailAddress { + return this._migratedBy; + } + public get fromVersion(): Version { + return this._fromVersion; + } + + /* -------- Utilities -------- */ + + public validate(): void { + if (!(this.migratedAt instanceof Date)) { + throw new SoloError(`Invalid migratedAt: ${this.migratedAt}`); + } + + if (!this.migratedBy || typeof this.migratedBy !== 'string') { + throw new SoloError(`Invalid migratedBy: ${this.migratedBy}`); + } + + if (!this.fromVersion || typeof this.fromVersion !== 'string') { + throw new SoloError(`Invalid fromVersion: ${this.fromVersion}`); + } + } + + public toObject(): IMigration { + return { + migratedAt: this.migratedAt, + migratedBy: this.migratedBy, + fromVersion: this.fromVersion, + }; + } +} diff --git a/src/core/config/remote/remote_config_data_wrapper.ts b/src/core/config/remote/remote_config_data_wrapper.ts new file mode 100644 index 000000000..e003b10f1 --- /dev/null +++ b/src/core/config/remote/remote_config_data_wrapper.ts @@ -0,0 +1,167 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {SoloError} from '../../errors.js'; +import * as version from '../../../../version.js'; +import * as yaml from 'yaml'; +import {RemoteConfigMetadata} from './metadata.js'; +import {ComponentsDataWrapper} from './components_data_wrapper.js'; +import * as constants from '../../constants.js'; +import type {Cluster, Version, Namespace, RemoteConfigData, RemoteConfigDataStructure} from './types.js'; +import type * as k8s from '@kubernetes/client-node'; +import type {ToObject, Validate} from '../../../types/index.js'; + +export class RemoteConfigDataWrapper implements Validate, ToObject { + private readonly _version: Version = '1.0.0'; + private _metadata: RemoteConfigMetadata; + private _clusters: Record; + private _components: ComponentsDataWrapper; + private _commandHistory: string[]; + private _lastExecutedCommand: string; + + public constructor(data: RemoteConfigData) { + this._metadata = data.metadata; + this._clusters = data.clusters; + this._components = data.components; + this._commandHistory = data.commandHistory; + this._lastExecutedCommand = data.lastExecutedCommand ?? ''; + this.validate(); + } + + //! -------- Modifiers -------- // + + public addCommandToHistory(command: string): void { + this._commandHistory.push(command); + this.lastExecutedCommand = command; + + if (this._commandHistory.length > constants.SOLO_REMOTE_CONFIG_MAX_COMMAND_IN_HISTORY) { + this._commandHistory.shift(); + } + + this.validate(); + } + + //! -------- Getters & Setters -------- // + + private get version(): Version { + return this._version; + } + + public get metadata(): RemoteConfigMetadata { + return this._metadata; + } + + public set metadata(metadata: RemoteConfigMetadata) { + this._metadata = metadata; + this.validate(); + } + + public get clusters(): Record { + return this._clusters; + } + + public set clusters(clusters: Record) { + this._clusters = clusters; + this.validate(); + } + + public get components(): ComponentsDataWrapper { + return this._components; + } + + public set components(components: ComponentsDataWrapper) { + this._components = components; + this.validate(); + } + + public get lastExecutedCommand(): string { + return this._lastExecutedCommand; + } + + private set lastExecutedCommand(lastExecutedCommand: string) { + this._lastExecutedCommand = lastExecutedCommand; + this.validate(); + } + + public get commandHistory(): string[] { + return this._commandHistory; + } + + private set commandHistory(commandHistory: string[]) { + this._commandHistory = commandHistory; + this.validate(); + } + + //! -------- Utilities -------- // + + public static compare(x: RemoteConfigDataWrapper, y: RemoteConfigDataWrapper): boolean { + // TODO + return true; + } + + public static fromConfigmap(configMap: k8s.V1ConfigMap): RemoteConfigDataWrapper { + const data = yaml.parse(configMap.data['remote-config-data']) as any; + + return new RemoteConfigDataWrapper({ + metadata: RemoteConfigMetadata.fromObject(data.metadata), + components: ComponentsDataWrapper.fromObject(data.components), + clusters: data.clusters, + commandHistory: data.commandHistory, + lastExecutedCommand: data.lastExecutedCommand, + }); + } + + public validate(): void { + if (!this._version || typeof this._version !== 'string') { + throw new SoloError(`Invalid remote config version: ${this._version}`); + } + + if (!this.metadata || !(this.metadata instanceof RemoteConfigMetadata)) { + throw new SoloError(`Invalid remote config metadata: ${this.metadata}`); + } + + if (!this.lastExecutedCommand || typeof this.lastExecutedCommand !== 'string') { + throw new SoloError(`Invalid remote config last executed command: ${this.lastExecutedCommand}`); + } + + if (!Array.isArray(this.commandHistory) || this.commandHistory.some(c => typeof c !== 'string')) { + throw new SoloError(`Invalid remote config command history: ${this.commandHistory}`); + } + + Object.entries(this.clusters).forEach(([cluster, namespace]: [Cluster, Namespace]): void => { + const clusterDataString = `cluster: { name: ${cluster}, namespace: ${namespace} }`; + + if (!cluster || typeof cluster !== 'string') { + throw new SoloError(`Invalid remote config clusters name: ${clusterDataString}`); + } + + if (!namespace || typeof namespace !== 'string') { + throw new SoloError(`Invalid remote config clusters namespace: ${clusterDataString}`); + } + }); + } + + public toObject(): RemoteConfigDataStructure { + return { + metadata: this.metadata.toObject(), + version: this.version, + clusters: this.clusters, + components: this.components.toObject(), + commandHistory: this.commandHistory, + lastExecutedCommand: this.lastExecutedCommand, + }; + } +} diff --git a/src/core/config/remote/remote_config_manager.ts b/src/core/config/remote/remote_config_manager.ts new file mode 100644 index 000000000..1d574c832 --- /dev/null +++ b/src/core/config/remote/remote_config_manager.ts @@ -0,0 +1,291 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import * as constants from '../../constants.js'; +import {MissingArgumentError, SoloError} from '../../errors.js'; +import {RemoteConfigDataWrapper} from './remote_config_data_wrapper.js'; +import chalk from 'chalk'; +import {RemoteConfigMetadata} from './metadata.js'; +import {flags} from '../../../commands/index.js'; +import * as yaml from 'yaml'; +import {ComponentsDataWrapper} from './components_data_wrapper.js'; +import type {K8} from '../../k8.js'; +import type {Cluster, Namespace} from './types.js'; +import type {SoloLogger} from '../../logging.js'; +import type {ListrTask} from 'listr2'; +import type {ConfigManager} from '../../config_manager.js'; +import type {LocalConfig} from '../local_config.js'; +import type {DeploymentStructure} from '../local_config_data.js'; +import type {ContextClusterStructure, Optional} from '../../../types/index.js'; +import type * as k8s from '@kubernetes/client-node'; + +interface ListrContext { + config: {contextCluster: ContextClusterStructure}; +} + +/** + * Uses Kubernetes ConfigMaps to manage the remote configuration data by creating, loading, modifying, + * and saving the configuration data to and from a Kubernetes cluster. + */ +export class RemoteConfigManager { + /** Stores the loaded remote configuration data. */ + private remoteConfig: Optional; + + /** + * @param k8 - The Kubernetes client used for interacting with ConfigMaps. + * @param logger - The logger for recording activity and errors. + * @param localConfig - Local configuration for the remote config. + * @param configManager - Manager to retrieve application flags and settings. + */ + public constructor( + private readonly k8: K8, + private readonly logger: SoloLogger, + private readonly localConfig: LocalConfig, + private readonly configManager: ConfigManager, + ) {} + + /* ---------- Getters ---------- */ + + public get currentCluster(): Cluster { + return this.localConfig.currentDeploymentName as Cluster; + } + + /** @returns the components data wrapper cloned */ + public get components(): ComponentsDataWrapper { + return this.remoteConfig.components.clone(); + } + + /* ---------- Readers and Modifiers ---------- */ + + /** + * Modifies the loaded remote configuration data using a provided callback function. + * The callback operates on the configuration data, which is then saved to the cluster. + * + * @param callback - an async function that modifies the remote configuration data. + * @throws {@link SoloError} if the configuration is not loaded before modification. + */ + public async modify(callback: (remoteConfig: RemoteConfigDataWrapper) => Promise): Promise { + if (!this.remoteConfig) { + return; + + // TODO see if this should be disabled to make it an optional feature + // throw new SoloError('Attempting to modify remote config without loading it first') + } + + await callback(this.remoteConfig); + await this.save(); + } + + /** + * Creates a new remote configuration in the Kubernetes cluster. + * Gathers data from the local configuration and constructs a new ConfigMap + * entry in the cluster with initial command history and metadata. + */ + private async create(): Promise { + const clusters: Record = {}; + + Object.entries(this.localConfig.deployments).forEach( + ([namespace, deployment]: [Namespace, DeploymentStructure]) => { + deployment.clusters.forEach(cluster => (clusters[cluster] = namespace)); + }, + ); + + this.remoteConfig = new RemoteConfigDataWrapper({ + metadata: new RemoteConfigMetadata(this.getNamespace(), new Date(), this.localConfig.userEmailAddress), + clusters, + components: ComponentsDataWrapper.initializeEmpty(), + lastExecutedCommand: 'deployment create', + commandHistory: ['deployment create'], + }); + + await this.createConfigMap(); + } + + /** + * Saves the currently loaded remote configuration data to the Kubernetes cluster. + * @throws {@link SoloError} if there is no remote configuration data to save. + */ + private async save(): Promise { + if (!this.remoteConfig) { + throw new SoloError('Attempted to save remote config without data'); + } + + await this.replaceConfigMap(); + } + + /** + * Loads the remote configuration from the Kubernetes cluster if it exists. + * @returns true if the configuration is loaded successfully. + */ + private async load(): Promise { + if (this.remoteConfig) return true; + + const configMap = await this.getConfigMap(); + if (!configMap) return false; + + this.remoteConfig = RemoteConfigDataWrapper.fromConfigmap(configMap); + return true; + } + + /* ---------- Listr Task Builders ---------- */ + + /** + * Builds a task for loading the remote configuration, intended for use with Listr task management. + * Checks if the configuration is already loaded, otherwise loads and adds the command to history. + * + * @param argv - arguments containing command input for historical reference. + * @returns a Listr task which loads the remote configuration. + */ + public buildLoadTask(argv: {_: string[]}): ListrTask { + const self = this; + + return { + title: 'Load remote config', + task: async (_, task): Promise => { + try { + self.setDefaultNamespaceIfNotSet(); + self.setDefaultContextIfNotSet(); + } catch { + return; // TODO + } + + if (!(await self.load())) { + task.title = `${task.title} - ${chalk.red('remote config not found')}`; + + // TODO see if this should be disabled to make it an optional feature + return; + // throw new SoloError('Failed to load remote config') + } + + const currentCommand = argv._.join(' '); + self.remoteConfig!.addCommandToHistory(currentCommand); + + await self.save(); + }, + }; + } + + /** + * Builds a task for creating a new remote configuration, intended for use with Listr task management. + * Merges cluster mappings from the provided context into the local configuration, then creates the remote config. + * + * @returns a Listr task which creates the remote configuration. + */ + public buildCreateTask(): ListrTask { + const self = this; + + return { + title: 'Create remote config', + task: async (_, task): Promise => { + const localConfigExists = this.localConfig.configFileExists(); + if (!localConfigExists) { + throw new SoloError("Local config doesn't exist"); + } + + if (await self.load()) { + task.title = `${task.title} - ${chalk.red('Remote config already exists')}}`; + + throw new SoloError('Remote config already exists'); + } + + await self.create(); + }, + }; + } + + /* ---------- Utilities ---------- */ + + public isLoaded(): boolean { + return !!this.remoteConfig; + } + + /** + * Retrieves the ConfigMap containing the remote configuration from the Kubernetes cluster. + * + * @returns the remote configuration data. + * @throws {@link SoloError} if the ConfigMap could not be read and the error is not a 404 status. + */ + private async getConfigMap(): Promise { + try { + return await this.k8.getNamespacedConfigMap(constants.SOLO_REMOTE_CONFIGMAP_NAME); + } catch (error: any) { + if (error.meta.statusCode !== 404) { + throw new SoloError('Failed to read remote config from cluster', error); + } + + return null; + } + } + + /** + * Creates a new ConfigMap entry in the Kubernetes cluster with the remote configuration data. + */ + private async createConfigMap(): Promise { + await this.k8.createNamespacedConfigMap( + constants.SOLO_REMOTE_CONFIGMAP_NAME, + constants.SOLO_REMOTE_CONFIGMAP_LABELS, + {'remote-config-data': yaml.stringify(this.remoteConfig.toObject())}, + ); + } + + /** Replaces an existing ConfigMap in the Kubernetes cluster with the current remote configuration data. */ + private async replaceConfigMap(): Promise { + await this.k8.replaceNamespacedConfigMap( + constants.SOLO_REMOTE_CONFIGMAP_NAME, + constants.SOLO_REMOTE_CONFIGMAP_LABELS, + {'remote-config-data': yaml.stringify(this.remoteConfig.toObject() as any)}, + ); + } + + private setDefaultNamespaceIfNotSet(): void { + if (this.configManager.hasFlag(flags.namespace)) return; + + if (!this.localConfig?.currentDeploymentName) { + this.logger.error('Current deployment name is not set in local config', this.localConfig); + throw new SoloError('Current deployment name is not set in local config'); + } + + // TODO: Current quick fix for commands where namespace is not passed + const namespace = this.localConfig.currentDeploymentName.replace(/^kind-/, ''); + + this.configManager.setFlag(flags.namespace, namespace); + } + + private setDefaultContextIfNotSet(): void { + if (this.configManager.hasFlag(flags.context)) return; + + const context = this.k8.getKubeConfig().currentContext; + + if (!context) { + this.logger.error("Context is not passed and default one can't be acquired", this.localConfig); + throw new SoloError("Context is not passed and default one can't be acquired"); + } + + this.configManager.setFlag(flags.context, context); + } + + // cluster will be retrieved from LocalConfig based the context to cluster mapping + + /** + * Retrieves the namespace value from the configuration manager's flags. + * @returns string - The namespace value if set. + */ + private getNamespace(): string { + const ns = this.configManager.getFlag(flags.namespace) as string; + if (!ns) throw new MissingArgumentError('namespace is not set'); + return ns; + } +} diff --git a/src/core/config/remote/remote_config_tasks.ts b/src/core/config/remote/remote_config_tasks.ts new file mode 100644 index 000000000..8f65905eb --- /dev/null +++ b/src/core/config/remote/remote_config_tasks.ts @@ -0,0 +1,311 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { + RelayComponent, + HaProxyComponent, + EnvoyProxyComponent, + MirrorNodeComponent, + ConsensusNodeComponent, +} from './components/index.js'; +import {ComponentType, ConsensusNodeStates} from './enumerations.js'; +import chalk from 'chalk'; +import {SoloError} from '../../errors.js'; + +import type {Listr, ListrTask} from 'listr2'; +import type {NodeAlias, NodeAliases} from '../../../types/aliases.js'; +import type {BaseCommand} from '../../../commands/base.js'; +import type {RelayCommand} from '../../../commands/relay.js'; +import type {NetworkCommand} from '../../../commands/network.js'; +import type {DeploymentCommand} from '../../../commands/deployment.js'; +import type {MirrorNodeCommand} from '../../../commands/mirror_node.js'; +import type {NodeCommandHandlers} from '../../../commands/node/handlers.js'; +import type {Optional} from '../../../types/index.js'; +import {ComponentsDataWrapper} from './components_data_wrapper.js'; + +/** + * Static class that handles all tasks related to remote config used by other commands. + */ +export class RemoteConfigTasks { + /* ----------- Create and Load ----------- */ + + /** + * Loads the remote config from the config class. + * + * @param argv - used to update the last executed command and command history + */ + public static loadRemoteConfig(this: BaseCommand, argv: any): ListrTask { + return this.remoteConfigManager.buildLoadTask(argv); + } + + /** Creates remote config. */ + public static createRemoteConfig(this: DeploymentCommand): ListrTask { + return this.remoteConfigManager.buildCreateTask(); + } + + /* ----------- Component Modifying ----------- */ + + /** Adds the relay component to remote config. */ + public static addRelayComponent(this: RelayCommand): ListrTask { + return { + title: 'Add relay component in remote config', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: async (ctx): Promise => { + await this.remoteConfigManager.modify(async remoteConfig => { + const { + config: {namespace, nodeAliases}, + } = ctx; + const cluster = this.remoteConfigManager.currentCluster; + + remoteConfig.components.add('relay', new RelayComponent('relay', cluster, namespace, nodeAliases)); + }); + }, + }; + } + + /** Remove the relay component from remote config. */ + public static removeRelayComponent(this: RelayCommand): ListrTask { + return { + title: 'Remove relay component from remote config', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: async (): Promise => { + await this.remoteConfigManager.modify(async remoteConfig => { + remoteConfig.components.remove('relay', ComponentType.Relay); + }); + }, + }; + } + + /** Adds the mirror node and mirror node explorer components to remote config. */ + public static addMirrorNodeAndMirrorNodeToExplorer(this: MirrorNodeCommand): ListrTask { + return { + title: 'Add mirror node and mirror node explorer to remote config', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: async (ctx): Promise => { + await this.remoteConfigManager.modify(async remoteConfig => { + const { + config: {namespace}, + } = ctx; + const cluster = this.remoteConfigManager.currentCluster; + + remoteConfig.components.add('mirrorNode', new MirrorNodeComponent('mirrorNode', cluster, namespace)); + + remoteConfig.components.add( + 'mirrorNodeExplorer', + new MirrorNodeComponent('mirrorNodeExplorer', cluster, namespace), + ); + }); + }, + }; + } + + /** Removes the mirror node and mirror node explorer components from remote config. */ + public static removeMirrorNodeAndMirrorNodeToExplorer(this: MirrorNodeCommand): ListrTask { + return { + title: 'Remove mirror node and mirror node explorer from remote config', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: async (): Promise => { + await this.remoteConfigManager.modify(async remoteConfig => { + remoteConfig.components.remove('mirrorNode', ComponentType.MirrorNode); + + remoteConfig.components.remove('mirrorNodeExplorer', ComponentType.MirrorNode); + }); + }, + }; + } + + /** Adds the consensus node, envoy and haproxy components to remote config. */ + public static addNodesAndProxies(this: NetworkCommand): ListrTask { + return { + title: 'Add node and proxies to remote config', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: async (ctx): Promise => { + const { + config: {namespace, nodeAliases}, + } = ctx; + const cluster = this.remoteConfigManager.currentCluster; + + await this.remoteConfigManager.modify(async remoteConfig => { + for (const nodeAlias of nodeAliases) { + remoteConfig.components.add( + nodeAlias, + new ConsensusNodeComponent(nodeAlias, cluster, namespace, ConsensusNodeStates.INITIALIZED), + ); + + remoteConfig.components.add( + `envoy-${nodeAlias}`, + new EnvoyProxyComponent(`envoy-${nodeAlias}`, cluster, namespace), + ); + + remoteConfig.components.add( + `haproxy-${nodeAlias}`, + new HaProxyComponent(`haproxy-${nodeAlias}`, cluster, namespace), + ); + } + }); + }, + }; + } + + /** Removes the consensus node, envoy and haproxy components from remote config. */ + public static removeNodeAndProxies(this: NodeCommandHandlers): ListrTask { + return { + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + title: 'Remove node and proxies from remote config', + task: async (): Promise => { + await this.remoteConfigManager.modify(async remoteConfig => { + remoteConfig.components.remove('Consensus node name', ComponentType.ConsensusNode); + remoteConfig.components.remove('Envoy proxy name', ComponentType.EnvoyProxy); + remoteConfig.components.remove('HaProxy name', ComponentType.HaProxy); + }); + }, + }; + } + + /** + * Changes the state from all consensus nodes components in remote config. + * + * @param state - to which to change the consensus node component + */ + public static changeAllNodeStates(this: NodeCommandHandlers, state: ConsensusNodeStates): ListrTask { + interface Context { + config: {namespace: string; nodeAliases: NodeAliases}; + } + + return { + title: `Change node state to ${state} in remote config`, + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: async (ctx: Context): Promise => { + await this.remoteConfigManager.modify(async remoteConfig => { + const { + config: {namespace, nodeAliases}, + } = ctx; + const cluster = this.remoteConfigManager.currentCluster; + + for (const nodeAlias of nodeAliases) { + remoteConfig.components.edit(nodeAlias, new ConsensusNodeComponent(nodeAlias, cluster, namespace, state)); + } + }); + }, + }; + } + + /** + * Creates tasks to validate that each node state is either one of the accepted states or not one of the excluded. + * + * @param acceptedStates - the state at which the nodes can be, not matching any of the states throws an error + * @param excludedStates - the state at which the nodes can't be, matching any of the states throws an error + */ + public static validateAllNodeStates( + this: NodeCommandHandlers, + {acceptedStates, excludedStates}: {acceptedStates?: ConsensusNodeStates[]; excludedStates?: ConsensusNodeStates[]}, + ): ListrTask { + interface Context { + config: {namespace: string; nodeAliases: NodeAliases}; + } + + return { + title: 'Validate nodes states', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: (ctx: Context, task): Listr => { + const nodeAliases = ctx.config.nodeAliases; + + const components = this.remoteConfigManager.components; + + const subTasks: ListrTask[] = nodeAliases.map(nodeAlias => ({ + title: `Validating state for node ${nodeAlias}`, + task: (_, task): void => { + const state = RemoteConfigTasks.validateNodeState(nodeAlias, components, acceptedStates, excludedStates); + + task.title += ` - ${chalk.green('valid state')}: ${chalk.cyan(state)}`; + }, + })); + + return task.newListr(subTasks, { + concurrent: false, + rendererOptions: {collapseSubtasks: false}, + }); + }, + }; + } + + /** + * Creates tasks to validate that specific node state is either one of the accepted states or not one of the excluded. + * + * @param acceptedStates - the state at which the node can be, not matching any of the states throws an error + * @param excludedStates - the state at which the node can't be, matching any of the states throws an error + */ + public static validateSingleNodeState( + this: NodeCommandHandlers, + {acceptedStates, excludedStates}: {acceptedStates?: ConsensusNodeStates[]; excludedStates?: ConsensusNodeStates[]}, + ): ListrTask { + interface Context { + config: {namespace: string; nodeAlias: NodeAlias}; + } + + return { + title: 'Validate nodes state', + skip: (): boolean => !this.remoteConfigManager.isLoaded(), + task: (ctx: Context, task): void => { + const nodeAlias = ctx.config.nodeAlias; + + task.title += ` ${nodeAlias}`; + + const components = this.remoteConfigManager.components; + + const state = RemoteConfigTasks.validateNodeState(nodeAlias, components, acceptedStates, excludedStates); + + task.title += ` - ${chalk.green('valid state')}: ${chalk.cyan(state)}`; + }, + }; + } + + /** + * @param nodeAlias - the alias of the node whose state to validate + * @param components - the component data wrapper + * @param acceptedStates - the state at which the node can be, not matching any of the states throws an error + * @param excludedStates - the state at which the node can't be, matching any of the states throws an error + */ + private static validateNodeState( + nodeAlias: NodeAlias, + components: ComponentsDataWrapper, + acceptedStates: Optional, + excludedStates: Optional, + ): ConsensusNodeStates { + let nodeComponent: ConsensusNodeComponent; + try { + nodeComponent = components.getComponent(ComponentType.ConsensusNode, nodeAlias); + } catch (e) { + throw new SoloError(`${nodeAlias} not found in remote config`); + } + + if (acceptedStates && !acceptedStates.includes(nodeComponent.state)) { + const errorMessageData = + `accepted states: ${acceptedStates.join(', ')}, ` + `current state: ${nodeComponent.state}`; + + throw new SoloError(`${nodeAlias} has invalid state - ` + errorMessageData); + } + + if (excludedStates && excludedStates.includes(nodeComponent.state)) { + const errorMessageData = + `excluded states: ${excludedStates.join(', ')}, ` + `current state: ${nodeComponent.state}`; + + throw new SoloError(`${nodeAlias} has invalid state - ` + errorMessageData); + } + + return nodeComponent.state; + } +} diff --git a/src/core/config/remote/types.ts b/src/core/config/remote/types.ts new file mode 100644 index 000000000..3756b4174 --- /dev/null +++ b/src/core/config/remote/types.ts @@ -0,0 +1,74 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import type {NodeAliases} from '../../../types/aliases.js'; +import type {Migration} from './migration.js'; +import type {ComponentsDataWrapper} from './components_data_wrapper.js'; +import type {RemoteConfigMetadata} from './metadata.js'; +import type {ComponentType, ConsensusNodeStates} from './enumerations.js'; + +export type EmailAddress = `${string}@${string}.${string}`; +export type Version = string; +export type Namespace = string; +export type Cluster = string; +export type Context = string; +export type ComponentName = string; + +export interface RemoteConfigMetadataStructure { + name: Namespace; + lastUpdatedAt: Date; + lastUpdateBy: EmailAddress; + migration?: Migration; +} + +export interface IMigration { + migratedAt: Date; + migratedBy: EmailAddress; + fromVersion: Version; +} + +export interface Component { + name: ComponentName; + cluster: Cluster; + namespace: Namespace; +} + +export interface IRelayComponent extends Component { + consensusNodeAliases: NodeAliases; +} + +export interface IConsensusNodeComponent extends Component { + state: ConsensusNodeStates; +} + +export interface RemoteConfigData { + metadata: RemoteConfigMetadata; + clusters: Record; + components: ComponentsDataWrapper; + lastExecutedCommand: string; + commandHistory: string[]; +} + +export type ComponentsDataStructure = Record>; + +export interface RemoteConfigDataStructure { + metadata: RemoteConfigMetadataStructure; + version: Version; + clusters: Record; + components: ComponentsDataStructure; + commandHistory: string[]; + lastExecutedCommand: string; +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 1407bbff8..2550090fe 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -19,10 +19,8 @@ import {AccountId, FileId} from '@hashgraph/sdk'; import {color, type ListrLogger, PRESET_TIMER} from 'listr2'; import path, {dirname, normalize} from 'path'; import {fileURLToPath} from 'url'; -import os from 'node:os'; export const ROOT_DIR = path.join(dirname(fileURLToPath(import.meta.url)), '..', '..'); -export const OS_USERNAME = os.userInfo().username; // -------------------- solo related constants --------------------------------------------------------------------- export const SOLO_HOME_DIR = process.env.SOLO_HOME || path.join(process.env.HOME as string, '.solo'); @@ -34,6 +32,9 @@ export const HELM = 'helm'; export const RESOURCES_DIR = normalize(path.join(ROOT_DIR, 'resources')); export const ROOT_CONTAINER = 'root-container'; +export const SOLO_REMOTE_CONFIGMAP_NAME = 'solo-remote-config'; +export const SOLO_REMOTE_CONFIGMAP_LABELS = {'solo.hedera.com/type': 'remote-config'}; +export const SOLO_REMOTE_CONFIG_MAX_COMMAND_IN_HISTORY = 50; // --------------- Hedera network and node related constants -------------------------------------------------------------------- export const HEDERA_CHAIN_ID = process.env.SOLO_CHAIN_ID || '298'; diff --git a/src/core/index.ts b/src/core/index.ts index 8d836aa6d..182f8e00a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -34,6 +34,7 @@ import {AccountManager} from './account_manager.js'; import {LeaseManager} from './lease/lease_manager.js'; import {CertificateManager} from './certificate_manager.js'; import {LocalConfig} from './config/local_config.js'; +import {RemoteConfigManager} from './config/remote/remote_config_manager.js'; // Expose components from the core module export { @@ -57,4 +58,5 @@ export { LeaseManager, CertificateManager, LocalConfig, + RemoteConfigManager, }; diff --git a/src/core/k8.ts b/src/core/k8.ts index da8ce9e7e..bdfb7ca70 100644 --- a/src/core/k8.ts +++ b/src/core/k8.ts @@ -895,7 +895,6 @@ export class K8 { * This simple server just forwards traffic from itself to a service running in kubernetes * -> localhost:localPort -> port-forward-tunnel -> kubernetes-pod:targetPort */ - async portForward(podName: PodName, localPort: number, podPort: number) { const ns = this._getNamespace(); const forwarder = new k8s.PortForward(this.kubeConfig, false); @@ -1202,6 +1201,20 @@ export class K8 { return resp.response.statusCode === 200.0; } + // --------------------------------------- Utility Methods --------------------------------------- // + + public async testClusterConnection(context: string): Promise { + this.kubeConfig.setCurrentContext(context); + + return await this.kubeConfig + .makeApiClient(k8s.CoreV1Api) + .listNamespace() + .then(() => true) + .catch(() => false); + } + + // --------------------------------------- Secret --------------------------------------- // + /** * retrieve the secret of the given namespace and label selector, if there is more than one, it returns the first * @param namespace - the namespace of the secret to search for @@ -1295,6 +1308,98 @@ export class K8 { return resp.response.statusCode === 200.0; } + /* ------------- ConfigMap ------------- */ + + /** + * @param name - name of the configmap + * @returns the configmap if found + * @throws SoloError - if the response if not found or the response is not OK + */ + public async getNamespacedConfigMap(name: string): Promise { + const {response, body} = await this.kubeClient.readNamespacedConfigMap(name, this._getNamespace()).catch(e => e); + + this.handleKubernetesClientError(response, body, 'Failed to get namespaced configmap'); + + return body as k8s.V1ConfigMap; + } + + /** + * @param name - for the config name + * @param labels - for the config metadata + * @param data - to contain in the config + */ + public async createNamespacedConfigMap( + name: string, + labels: Record, + data: Record, + ): Promise { + const namespace = this._getNamespace(); + + const configMap = new k8s.V1ConfigMap(); + configMap.data = data; + + const metadata = new k8s.V1ObjectMeta(); + metadata.name = name; + metadata.namespace = namespace; + metadata.labels = labels; + configMap.metadata = metadata; + try { + const resp = await this.kubeClient.createNamespacedConfigMap(namespace, configMap); + + return resp.response.statusCode === 201; + } catch (e: Error | any) { + throw new SoloError( + `failed to create configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, + e, + ); + } + } + + /** + * @param name - for the config name + * @param labels - for the config metadata + * @param data - to contain in the config + */ + public async replaceNamespacedConfigMap( + name: string, + labels: Record, + data: Record, + ): Promise { + const namespace = this._getNamespace(); + + const configMap = new k8s.V1ConfigMap(); + configMap.data = data; + + const metadata = new k8s.V1ObjectMeta(); + metadata.name = name; + metadata.namespace = namespace; + metadata.labels = labels; + configMap.metadata = metadata; + try { + const resp = await this.kubeClient.replaceNamespacedConfigMap(name, namespace, configMap); + + return resp.response.statusCode === 201; + } catch (e: Error | any) { + throw new SoloError( + `failed to create configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, + e, + ); + } + } + + public async deleteNamespacedConfigMap(name: string, namespace: string): Promise { + try { + const resp = await this.kubeClient.deleteNamespacedConfigMap(name, namespace); + + return resp.response.statusCode === 201; + } catch (e: Error | any) { + throw new SoloError( + `failed to create configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, + e, + ); + } + } + // --------------------------------------- LEASES --------------------------------------- // async createNamespacedLease(namespace: string, leaseName: string, holderName: string, durationSeconds = 20) { const lease = new k8s.V1Lease(); @@ -1312,7 +1417,7 @@ export class K8 { const {response, body} = await this.coordinationApiClient.createNamespacedLease(namespace, lease).catch(e => e); - this._handleKubernetesClientError(response, body, 'Failed to create namespaced lease'); + this.handleKubernetesClientError(response, body, 'Failed to create namespaced lease'); return body as k8s.V1Lease; } @@ -1320,7 +1425,7 @@ export class K8 { async readNamespacedLease(leaseName: string, namespace: string) { const {response, body} = await this.coordinationApiClient.readNamespacedLease(leaseName, namespace).catch(e => e); - this._handleKubernetesClientError(response, body, 'Failed to read namespaced lease'); + this.handleKubernetesClientError(response, body, 'Failed to read namespaced lease'); return body as k8s.V1Lease; } @@ -1332,7 +1437,7 @@ export class K8 { .replaceNamespacedLease(leaseName, namespace, lease) .catch(e => e); - this._handleKubernetesClientError(response, body, 'Failed to renew namespaced lease'); + this.handleKubernetesClientError(response, body, 'Failed to renew namespaced lease'); return body as k8s.V1Lease; } @@ -1346,7 +1451,7 @@ export class K8 { .replaceNamespacedLease(lease.metadata.name, lease.metadata.namespace, lease) .catch(e => e); - this._handleKubernetesClientError(response, body, 'Failed to transfer namespaced lease'); + this.handleKubernetesClientError(response, body, 'Failed to transfer namespaced lease'); return body as k8s.V1Lease; } @@ -1354,16 +1459,25 @@ export class K8 { async deleteNamespacedLease(name: string, namespace: string) { const {response, body} = await this.coordinationApiClient.deleteNamespacedLease(name, namespace).catch(e => e); - this._handleKubernetesClientError(response, body, 'Failed to delete namespaced lease'); + this.handleKubernetesClientError(response, body, 'Failed to delete namespaced lease'); return body as k8s.V1Status; } - private _handleKubernetesClientError(response: http.IncomingMessage, error: Error | any, errorMessage: string) { - const statusCode = +response.statusCode; + /* ------------- Utilities ------------- */ + + /** + * @param response - response object from the kubeclient call + * @param error - body of the response becomes the error if the status is not OK + * @param errorMessage - the error message to be passed in case it fails + * + * @throws SoloError - if the status code is not OK + */ + private handleKubernetesClientError(response: http.IncomingMessage, error: Error | any, errorMessage: string): void { + const statusCode = +response?.statusCode || 500; if (statusCode <= 202) return; - errorMessage += `, statusCode: ${response.statusCode}`; + errorMessage += `, statusCode: ${statusCode}`; this.logger.error(errorMessage, error); throw new SoloError(errorMessage, errorMessage, {statusCode: statusCode}); diff --git a/src/core/lease/lease.ts b/src/core/lease/lease.ts index fdf0202a8..cd410d3ef 100644 --- a/src/core/lease/lease.ts +++ b/src/core/lease/lease.ts @@ -303,7 +303,7 @@ export class Lease { if (e.meta.statusCode !== 404) { throw new LeaseAcquisitionError( - 'failed to read existing leases, unexpected server response of' + `'${e.meta.statusCode}' received`, + 'failed to read existing leases, unexpected server response of ' + `'${e.meta.statusCode}' received`, e, ); } diff --git a/src/core/network_node_services.ts b/src/core/network_node_services.ts index d8fa63652..fccdf8349 100644 --- a/src/core/network_node_services.ts +++ b/src/core/network_node_services.ts @@ -15,7 +15,7 @@ * */ -import {type NodeAlias, type PodName} from '../types/aliases.js'; +import type {NodeAlias, PodName} from '../types/aliases.js'; export class NetworkNodeServices { public readonly nodeAlias: NodeAlias; diff --git a/src/core/profile_manager.ts b/src/core/profile_manager.ts index 7eab57bff..e31f42482 100644 --- a/src/core/profile_manager.ts +++ b/src/core/profile_manager.ts @@ -25,9 +25,9 @@ import {getNodeAccountMap} from './helpers.js'; import * as semver from 'semver'; import {readFile, writeFile} from 'fs/promises'; -import {type SoloLogger} from './logging.js'; -import {type SemVer} from 'semver'; -import {type NodeAlias, type NodeAliases} from '../types/aliases.js'; +import type {SoloLogger} from './logging.js'; +import type {SemVer} from 'semver'; +import type {NodeAlias, NodeAliases} from '../types/aliases.js'; const consensusSidecars = [ 'recordStreamUploader', diff --git a/src/core/templates.ts b/src/core/templates.ts index ca95f3edf..855c93d5a 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -22,6 +22,9 @@ import {constants} from './index.js'; import {type AccountId} from '@hashgraph/sdk'; import type {NodeAlias, PodName} from '../types/aliases.js'; import {GrpcProxyTlsEnums} from './enumerations.js'; +import type {ContextClusterStructure} from '../types/index.js'; +import type {Cluster, Context} from './config/remote/types.js'; +import {flags} from '../commands/index.js'; export class Templates { public static renderNetworkPodName(nodeAlias: NodeAlias): PodName { @@ -223,7 +226,33 @@ export class Templates { } } - static parseClusterAliases(clusterAliases: string) { - return clusterAliases ? clusterAliases.split(',') : []; + /** + * Parsed and validates the unparsed value of flag clusterMappings + * + * @param unparsed - value of flag clusterMappings + */ + public static parseContextCluster(unparsed: string): ContextClusterStructure { + const mapping = {}; + const errorMessage = `Invalid context in context-cluster, expected structure: ${flags.contextClusterUnparsed.definition.describe}`; + + unparsed.split(',').forEach(data => { + const [context, cluster] = data.split('=') as [Context, Cluster]; + + if (!context || typeof context !== 'string') { + throw new SoloError(errorMessage, null, {data}); + } + + if (!cluster || typeof cluster !== 'string') { + throw new SoloError(errorMessage, null, {data}); + } + + mapping[context] = cluster; + }); + + return mapping; + } + + static parseClusterAliases(clusters: string) { + return clusters ? clusters.split(',') : []; } } diff --git a/src/core/validator_decorators.ts b/src/core/validator_decorators.ts index 028064475..caaf26e44 100644 --- a/src/core/validator_decorators.ts +++ b/src/core/validator_decorators.ts @@ -34,13 +34,11 @@ export const IsDeployments = (validationOptions?: ValidationOptions) => { if (Object.keys(value).length === 0) return true; const keys = Object.keys(value); - return keys.every(key => { if (typeof key !== 'string') return false; if (!isObject(value[key])) return false; - if (!Array.isArray(value[key].clusterAliases)) return false; - if (!value[key].clusterAliases.every(val => typeof val === 'string')) return false; - + if (!Array.isArray(value[key].clusters)) return false; + if (!value[key].clusters.every(val => typeof val === 'string')) return false; return true; }); }, diff --git a/src/index.ts b/src/index.ts index 58a71bb5a..b1f7a833e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,9 @@ import { AccountManager, LeaseManager, CertificateManager, - helpers, LocalConfig, + helpers, + RemoteConfigManager, } from './core/index.js'; import 'dotenv/config'; import {K8} from './core/k8.js'; @@ -74,10 +75,9 @@ export function main(argv: any) { const leaseRenewalService: LeaseRenewalService = new IntervalLeaseRenewalService(); const leaseManager = new LeaseManager(k8, configManager, logger, leaseRenewalService); const certificateManager = new CertificateManager(k8, logger, configManager); - const localConfig = new LocalConfig( - path.join(constants.SOLO_CACHE_DIR, constants.DEFAULT_LOCAL_CONFIG_FILE), - logger, - ); + const localConfigPath = path.join(constants.SOLO_CACHE_DIR, constants.DEFAULT_LOCAL_CONFIG_FILE); + const localConfig = new LocalConfig(localConfigPath, logger, configManager); + const remoteConfigManager = new RemoteConfigManager(k8, logger, localConfig, configManager); // set cluster and namespace in the global configManager from kubernetes context // so that we don't need to prompt the user @@ -98,11 +98,12 @@ export function main(argv: any) { accountManager, profileManager, leaseManager, + remoteConfigManager, certificateManager, localConfig, }; - const processArguments = (argv: any, yargs: any) => { + const processArguments = (argv: any, yargs: any): any => { if (argv._[0] === 'init') { configManager.reset(); } diff --git a/src/types/index.ts b/src/types/index.ts index 4a45eb65a..215bb1963 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,8 +32,10 @@ import type { AccountManager, LeaseManager, CertificateManager, + RemoteConfigManager, LocalConfig, } from '../core/index.js'; +import type {Cluster, Context} from '../core/config/remote/types.js'; import {type BaseCommand} from '../commands/base.js'; export interface NodeKeyObject { @@ -93,8 +95,38 @@ export interface Opts { leaseManager: LeaseManager; certificateManager: CertificateManager; localConfig: LocalConfig; + remoteConfigManager: RemoteConfigManager; } export interface CommandHandlers { parent: BaseCommand; } + +/** + * Generic type for representing optional types + */ +export type Optional = T | undefined; + +export type ContextClusterStructure = Record; + +/** + * Interface for capsuling validating for class's own properties + */ +export interface Validate { + /** + * Validates all properties of the class and throws if data is invalid + */ + validate(): void; +} + +/** + * Interface for converting a class to a plain object. + */ +export interface ToObject { + /** + * Converts the class instance to a plain object. + * + * @returns the plain object representation of the class. + */ + toObject(): T; +} diff --git a/test/e2e/integration/commands/init.test.ts b/test/e2e/integration/commands/init.test.ts index a87401981..7bec5ca62 100644 --- a/test/e2e/integration/commands/init.test.ts +++ b/test/e2e/integration/commands/init.test.ts @@ -27,10 +27,11 @@ import { K8, KeyManager, LeaseManager, + LocalConfig, logging, PackageDownloader, + RemoteConfigManager, Zippy, - LocalConfig, } from '../../../../src/core/index.js'; import {SECONDS} from '../../../../src/core/constants.js'; import sinon from 'sinon'; @@ -50,12 +51,13 @@ describe('InitCommand', () => { const helm = new Helm(testLogger); const chartManager = new ChartManager(helm, testLogger); const configManager = new ConfigManager(testLogger); - const localConfig = new LocalConfig(path.join(BASE_TEST_DIR, 'local-config.yaml'), testLogger); + let k8: K8; + let localConfig: LocalConfig; const keyManager = new KeyManager(testLogger); let leaseManager: LeaseManager; - let k8: K8; + let remoteConfigManager: RemoteConfigManager; let sandbox = sinon.createSandbox(); let initCmd: InitCommand; @@ -64,6 +66,8 @@ describe('InitCommand', () => { sandbox = sinon.createSandbox(); sandbox.stub(K8.prototype, 'init').callsFake(() => this); k8 = new K8(configManager, testLogger); + localConfig = new LocalConfig(path.join(BASE_TEST_DIR, 'local-config.yaml'), testLogger, configManager); + remoteConfigManager = new RemoteConfigManager(k8, testLogger, localConfig, configManager); leaseManager = new LeaseManager(k8, configManager, testLogger, new IntervalLeaseRenewalService()); // @ts-ignore initCmd = new InitCommand({ @@ -76,6 +80,7 @@ describe('InitCommand', () => { keyManager, leaseManager, localConfig, + remoteConfigManager, }); }); diff --git a/test/e2e/integration/core/remote_config_manager.test.ts b/test/e2e/integration/core/remote_config_manager.test.ts new file mode 100644 index 000000000..fdf279b06 --- /dev/null +++ b/test/e2e/integration/core/remote_config_manager.test.ts @@ -0,0 +1,151 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {it, describe, after, before} from 'mocha'; +import {expect} from 'chai'; + +import {constants, LocalConfig, RemoteConfigManager} from '../../../../src/core/index.js'; +import * as fs from 'fs'; +import {e2eTestSuite, getDefaultArgv, getTestCacheDir, TEST_CLUSTER} from '../../../test_util.js'; +import {flags} from '../../../../src/commands/index.js'; +import * as version from '../../../../version.js'; +import {MINUTES, SECONDS, SOLO_REMOTE_CONFIGMAP_NAME} from '../../../../src/core/constants.js'; +import path from 'path'; +import {SoloError} from '../../../../src/core/errors.js'; +import {RemoteConfigDataWrapper} from '../../../../src/core/config/remote/remote_config_data_wrapper.js'; + +const defaultTimeout = 20 * SECONDS; + +const namespace = 'remote-config-manager-e2e'; +const argv = getDefaultArgv(); +const testCacheDir = getTestCacheDir(); +argv[flags.cacheDir.name] = testCacheDir; +argv[flags.namespace.name] = namespace; +argv[flags.nodeAliasesUnparsed.name] = 'node1'; +argv[flags.clusterName.name] = TEST_CLUSTER; +argv[flags.soloChartVersion.name] = version.SOLO_CHART_VERSION; +argv[flags.generateGossipKeys.name] = true; +argv[flags.generateTlsKeys.name] = true; +// set the env variable SOLO_CHARTS_DIR if developer wants to use local Solo charts +argv[flags.chartDirectory.name] = process.env.SOLO_CHARTS_DIR ?? undefined; + +e2eTestSuite( + namespace, + argv, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + bootstrapResp => { + describe('RemoteConfigManager', async () => { + const k8 = bootstrapResp.opts.k8; + const logger = bootstrapResp.opts.logger; + const configManager = bootstrapResp.opts.configManager; + const filePath = path.join(constants.SOLO_CACHE_DIR, constants.DEFAULT_LOCAL_CONFIG_FILE); + + const localConfig = new LocalConfig(filePath, logger, configManager); + const remoteConfigManager = new RemoteConfigManager(k8, logger, localConfig, configManager); + + const email = 'john@gmail.com'; + + localConfig.userEmailAddress = email; + localConfig.deployments = {[namespace]: {clusters: [`kind-${namespace}`]}}; + localConfig.currentDeploymentName = namespace; + + after(async function () { + this.timeout(3 * MINUTES); + await k8.deleteNamespace(namespace); + }); + + before(function () { + this.timeout(defaultTimeout); + + if (!fs.existsSync(testCacheDir)) { + fs.mkdirSync(testCacheDir); + } + }); + + it('Attempting to load and save without existing remote config should fail', async () => { + // @ts-ignore + expect(await remoteConfigManager.load()).to.equal(false); + + // @ts-ignore + await expect(remoteConfigManager.save()).to.be.rejectedWith( + SoloError, + 'Attempted to save remote config without data', + ); + }); + + it('isLoaded() should return false if config is not loaded', async () => { + expect(remoteConfigManager.isLoaded()).to.not.be.ok; + }); + + it('isLoaded() should return true if config is loaded', async () => { + // @ts-ignore + await remoteConfigManager.create(); + + expect(remoteConfigManager.isLoaded()).to.be.ok; + }); + + it('should be able to use create method to populate the configMap', async () => { + // @ts-ignore + const remoteConfigData = remoteConfigManager.remoteConfig; + + expect(remoteConfigData).to.be.ok; + expect(remoteConfigData).to.be.instanceOf(RemoteConfigDataWrapper); + + expect(remoteConfigData.metadata.lastUpdatedAt).to.be.instanceOf(Date); + expect(remoteConfigData.metadata.lastUpdateBy).to.equal(email); + expect(remoteConfigData.metadata.migration).not.to.be.ok; + + expect(remoteConfigData.lastExecutedCommand).to.equal('deployment create'); + expect(remoteConfigData.commandHistory).to.deep.equal(['deployment create']); + + // @ts-ignore + expect(await remoteConfigManager.load()).to.equal(true); + + // @ts-ignore + expect(remoteConfigData.toObject()).to.deep.equal(remoteConfigManager.remoteConfig.toObject()); + }); + + it('should be able to mutate remote config with the modify method', async () => { + // @ts-ignore + const remoteConfigData = remoteConfigManager.remoteConfig; + + const oldRemoteConfig = remoteConfigData.components.clone(); + + await remoteConfigManager.modify(async remoteConfig => { + remoteConfig.metadata.makeMigration('email@address.com', '1.0.0'); + }); + + // @ts-ignore + expect(oldRemoteConfig.toObject()).not.to.deep.equal(remoteConfigManager.remoteConfig.toObject()); + + // @ts-ignore + const updatedRemoteConfig = remoteConfigManager.remoteConfig; + + // @ts-ignore + await remoteConfigManager.load(); + + // @ts-ignore + expect(remoteConfigManager.remoteConfig.toObject()).to.deep.equal(updatedRemoteConfig.toObject()); + }); + }); + }, +); diff --git a/test/test_util.ts b/test/test_util.ts index 8e093e945..c6fc754ce 100644 --- a/test/test_util.ts +++ b/test/test_util.ts @@ -45,6 +45,7 @@ import { Zippy, AccountManager, CertificateManager, + RemoteConfigManager, LocalConfig, } from '../src/core/index.js'; import {AccountBalanceQuery, AccountCreateTransaction, Hbar, HbarUnit, PrivateKey} from '@hashgraph/sdk'; @@ -106,6 +107,7 @@ interface TestOpts { profileManager: ProfileManager; leaseManager: LeaseManager; certificateManager: CertificateManager; + remoteConfigManager: RemoteConfigManager; localConfig: LocalConfig; } @@ -153,7 +155,8 @@ export function bootstrapTestVariables( const profileManager = new ProfileManager(testLogger, configManager); const leaseManager = new LeaseManager(k8, configManager, testLogger, new IntervalLeaseRenewalService()); const certificateManager = new CertificateManager(k8, testLogger, configManager); - const localConfig = new LocalConfig(path.join(BASE_TEST_DIR, 'local-config.yaml'), testLogger); + const localConfig = new LocalConfig(path.join(BASE_TEST_DIR, 'local-config.yaml'), testLogger, configManager); + const remoteConfigManager = new RemoteConfigManager(k8, testLogger, localConfig, configManager); const opts: TestOpts = { logger: testLogger, @@ -171,6 +174,7 @@ export function bootstrapTestVariables( leaseManager, certificateManager, localConfig, + remoteConfigManager, }; const initCmd = initCmdArg || new InitCommand(opts); @@ -454,13 +458,13 @@ export const testLocalConfigData = { userEmailAddress: 'john.doe@example.com', deployments: { deployment: { - clusterAliases: ['cluster-1'], + clusters: ['cluster-1'], }, 'deployment-2': { - clusterAliases: ['cluster-2'], + clusters: ['cluster-2'], }, 'deployment-3': { - clusterAliases: ['cluster-3'], + clusters: ['cluster-3'], }, }, currentDeploymentName: 'deployment', diff --git a/test/unit/commands/base.test.ts b/test/unit/commands/base.test.ts index 72f1ffa82..94ba68e8a 100644 --- a/test/unit/commands/base.test.ts +++ b/test/unit/commands/base.test.ts @@ -27,6 +27,7 @@ import { constants, K8, LocalConfig, + RemoteConfigManager, } from '../../../src/core/index.js'; import {BaseCommand} from '../../../src/commands/base.js'; import * as flags from '../../../src/commands/flags.js'; @@ -47,7 +48,8 @@ describe('BaseCommand', () => { const helmDepManager = new HelmDependencyManager(downloader, zippy, testLogger); const depManagerMap = new Map().set(constants.HELM, helmDepManager); const depManager = new DependencyManager(testLogger, depManagerMap); - const localConfig = new LocalConfig(path.join(BASE_TEST_DIR, 'local-config.yaml'), testLogger); + const localConfig = new LocalConfig(path.join(BASE_TEST_DIR, 'local-config.yaml'), testLogger, configManager); + const remoteConfigManager = new RemoteConfigManager({} as any, testLogger, localConfig, configManager); let sandbox = sinon.createSandbox(); @@ -68,6 +70,7 @@ describe('BaseCommand', () => { configManager, depManager, localConfig, + remoteConfigManager, }); }); diff --git a/test/unit/commands/context.test.ts b/test/unit/commands/context.test.ts index a0a2030cd..3d4116950 100644 --- a/test/unit/commands/context.test.ts +++ b/test/unit/commands/context.test.ts @@ -33,6 +33,7 @@ import { PackageDownloader, PlatformInstaller, ProfileManager, + RemoteConfigManager, } from '../../../src/core/index.js'; import {getTestCacheDir, testLocalConfigData} from '../../test_util.js'; import {BaseCommand} from '../../../src/commands/base.js'; @@ -69,14 +70,16 @@ describe('ContextCommandTasks unit tests', () => { k8Stub.getKubeConfig.returns(kubeConfigStub); + const configManager = sinon.createStubInstance(ConfigManager); + return { logger: loggerStub, helm: sinon.createStubInstance(Helm), k8: k8Stub, chartManager: sinon.createStubInstance(ChartManager), - configManager: sinon.createStubInstance(ConfigManager), + configManager, depManager: sinon.createStubInstance(DependencyManager), - localConfig: new LocalConfig(filePath, loggerStub), + localConfig: new LocalConfig(filePath, loggerStub, configManager), downloader: sinon.createStubInstance(PackageDownloader), keyManager: sinon.createStubInstance(KeyManager), accountManager: sinon.createStubInstance(AccountManager), @@ -84,6 +87,7 @@ describe('ContextCommandTasks unit tests', () => { profileManager: sinon.createStubInstance(ProfileManager), leaseManager: sinon.createStubInstance(LeaseManager), certificateManager: sinon.createStubInstance(CertificateManager), + remoteConfigManager: sinon.createStubInstance(RemoteConfigManager), } as Opts; }; @@ -146,21 +150,21 @@ describe('ContextCommandTasks unit tests', () => { [flags.context.name]: 'context-2', }; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); // @ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-2']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); }); it('should prompt for all flags if none are provided', async () => { const argv = {}; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); //@ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-3'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-3']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-3']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); expect(promptMap.get(flags.namespace.name)).to.have.been.calledOnce; expect(promptMap.get(flags.clusterName.name)).to.have.been.calledOnce; @@ -173,11 +177,11 @@ describe('ContextCommandTasks unit tests', () => { [flags.context.name]: 'context-2', }; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); // @ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-3'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-2']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); expect(promptMap.get(flags.namespace.name)).to.have.been.calledOnce; expect(promptMap.get(flags.clusterName.name)).to.not.have.been.called; @@ -190,11 +194,11 @@ describe('ContextCommandTasks unit tests', () => { [flags.context.name]: 'context-2', }; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); // @ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-3']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-3']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); expect(promptMap.get(flags.namespace.name)).to.not.have.been.called; expect(promptMap.get(flags.clusterName.name)).to.have.been.calledOnce; @@ -207,11 +211,11 @@ describe('ContextCommandTasks unit tests', () => { [flags.clusterName.name]: 'cluster-2', }; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); // @ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-2']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); expect(promptMap.get(flags.namespace.name)).to.not.have.been.called; expect(promptMap.get(flags.clusterName.name)).to.not.have.been.called; @@ -225,11 +229,11 @@ describe('ContextCommandTasks unit tests', () => { [flags.quiet.name]: 'true', }; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); // @ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-3']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-3']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); expect(promptMap.get(flags.namespace.name)).to.not.have.been.called; expect(promptMap.get(flags.clusterName.name)).to.not.have.been.called; @@ -243,11 +247,11 @@ describe('ContextCommandTasks unit tests', () => { [flags.quiet.name]: 'true', }; - await runUpdateLocalConfigTask(argv); - localConfig = new LocalConfig(filePath, loggerStub); + await runUpdateLocalConfigTask(argv); // @ts-ignore + localConfig = new LocalConfig(filePath, loggerStub, command.configManager); expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusterAliases).to.deep.equal(['cluster-2']); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); expect(promptMap.get(flags.namespace.name)).to.not.have.been.called; expect(promptMap.get(flags.clusterName.name)).to.not.have.been.called; diff --git a/test/unit/commands/node.test.ts b/test/unit/commands/node.test.ts index 03afe18fb..d58cc77e3 100644 --- a/test/unit/commands/node.test.ts +++ b/test/unit/commands/node.test.ts @@ -40,20 +40,20 @@ describe('NodeCommand unit tests', () => { it('should throw an error if platformInstaller is not provided', () => { opts.downloader = sinon.stub(); - expect(() => new NodeCommand(opts)).to.throw('An instance of core/PlatformInstaller is required'); + expect(() => new NodeCommand(opts)).to.throw('An instance of core/config/RemoteConfigManager is required'); }); it('should throw an error if keyManager is not provided', () => { opts.downloader = sinon.stub(); opts.platformInstaller = sinon.stub(); - expect(() => new NodeCommand(opts)).to.throw('An instance of core/KeyManager is required'); + expect(() => new NodeCommand(opts)).to.throw('An instance of core/config/RemoteConfigManager is required'); }); it('should throw an error if accountManager is not provided', () => { opts.downloader = sinon.stub(); opts.platformInstaller = sinon.stub(); opts.keyManager = sinon.stub(); - expect(() => new NodeCommand(opts)).to.throw('An instance of core/AccountManager is required'); + expect(() => new NodeCommand(opts)).to.throw('An instance of core/config/RemoteConfigManager is required'); }); }); }); diff --git a/test/unit/core/local_config.test.ts b/test/unit/core/local_config.test.ts index 40b2fd086..520d07a2d 100644 --- a/test/unit/core/local_config.test.ts +++ b/test/unit/core/local_config.test.ts @@ -14,22 +14,25 @@ * limitations under the License. * */ -import {LocalConfig} from '../../../src/core/config/local_config.js'; +import {type ConfigManager, LocalConfig} from '../../../src/core/index.js'; import fs from 'fs'; import {stringify} from 'yaml'; import {expect} from 'chai'; import {MissingArgumentError, SoloError} from '../../../src/core/errors.js'; import {getTestCacheDir, testLogger, testLocalConfigData} from '../../test_util.js'; +import type {EmailAddress} from '../../../src/core/config/remote/types.js'; import {ErrorMessages} from '../../../src/core/error_messages.js'; describe('LocalConfig', () => { - let localConfig; + let localConfig: LocalConfig; + const configManager = {} as unknown as ConfigManager; + const filePath = `${getTestCacheDir('LocalConfig')}/localConfig.yaml`; const config = testLocalConfigData; const expectFailedValidation = expectedMessage => { try { - new LocalConfig(filePath, testLogger); + new LocalConfig(filePath, testLogger, configManager); expect.fail('Expected an error to be thrown'); } catch (error) { expect(error).to.be.instanceOf(SoloError); @@ -39,7 +42,7 @@ describe('LocalConfig', () => { beforeEach(async () => { await fs.promises.writeFile(filePath, stringify(config)); - localConfig = new LocalConfig(filePath, testLogger); + localConfig = new LocalConfig(filePath, testLogger, configManager); }); afterEach(async () => { @@ -60,13 +63,13 @@ describe('LocalConfig', () => { await localConfig.write(); // reinitialize with updated config file - const newConfig = new LocalConfig(filePath, testLogger); + const newConfig = new LocalConfig(filePath, testLogger, configManager); expect(newConfig.userEmailAddress).to.eq(newEmailAddress); }); it('should not set an invalid email as user email address', async () => { try { - localConfig.setUserEmailAddress('invalidEmail'); + localConfig.setUserEmailAddress('invalidEmail' as EmailAddress); expect.fail('expected an error to be thrown'); } catch (error) { expect(error).to.be.instanceOf(SoloError); @@ -76,10 +79,10 @@ describe('LocalConfig', () => { it('should set deployments', async () => { const newDeployments = { deployment: { - clusterAliases: ['cluster-1', 'context-1'], + clusters: ['cluster-1', 'context-1'], }, 'deployment-2': { - clusterAliases: ['cluster-3', 'context-3'], + clusters: ['cluster-3', 'context-3'], }, }; @@ -87,18 +90,18 @@ describe('LocalConfig', () => { expect(localConfig.deployments).to.deep.eq(newDeployments); await localConfig.write(); - const newConfig = new LocalConfig(filePath, testLogger); + const newConfig = new LocalConfig(filePath, testLogger, configManager); expect(newConfig.deployments).to.deep.eq(newDeployments); }); it('should not set invalid deployments', async () => { - const validDeployment = {clusterAliases: ['cluster-3', 'cluster-4']}; + const validDeployment = {clusters: ['cluster-3', 'cluster-4']}; const invalidDeployments = [ {foo: ['bar']}, - {clusterAliases: [5, 6, 7]}, - {clusterAliases: 'bar'}, - {clusterAliases: 5}, - {clusterAliases: {foo: 'bar '}}, + {clusters: [5, 6, 7]}, + {clusters: 'bar'}, + {clusters: 5}, + {clusters: {foo: 'bar '}}, ]; for (const invalidDeployment of invalidDeployments) { @@ -108,7 +111,7 @@ describe('LocalConfig', () => { }; try { - localConfig.setDeployments(deployments); + localConfig.setDeployments(deployments as any); expect.fail('expected an error to be thrown'); } catch (error) { expect(error).to.be.instanceOf(SoloError); @@ -123,6 +126,7 @@ describe('LocalConfig', () => { }; try { + // @ts-ignore localConfig.setContextMappings(invalidContextMappings); expect.fail('expected an error to be thrown'); } catch (error) { @@ -141,14 +145,14 @@ describe('LocalConfig', () => { expect(localConfig.currentDeploymentName).to.eq(newCurrentDeployment); await localConfig.write(); - const newConfig = new LocalConfig(filePath, testLogger); + const newConfig = new LocalConfig(filePath, testLogger, configManager); expect(newConfig.currentDeploymentName).to.eq(newCurrentDeployment); }); it('should not set invalid or non-existent current deployment', async () => { const invalidCurrentDeploymentName = 5; try { - localConfig.setCurrentDeployment(invalidCurrentDeploymentName); + localConfig.setCurrentDeployment(invalidCurrentDeploymentName as any); expect.fail('expected an error to be thrown'); } catch (error) { expect(error).to.be.instanceOf(SoloError); @@ -165,7 +169,7 @@ describe('LocalConfig', () => { it('should throw an error if file path is not set', async () => { try { - new LocalConfig('', testLogger); + new LocalConfig('', testLogger, configManager); expect.fail('Expected an error to be thrown'); } catch (error) { expect(error).to.be.instanceOf(MissingArgumentError); diff --git a/test/unit/core/remote_config/components.ts b/test/unit/core/remote_config/components.ts new file mode 100644 index 000000000..32947b475 --- /dev/null +++ b/test/unit/core/remote_config/components.ts @@ -0,0 +1,246 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; + +import { + BaseComponent, + RelayComponent, + HaProxyComponent, + MirrorNodeComponent, + EnvoyProxyComponent, + ConsensusNodeComponent, + MirrorNodeExplorerComponent, +} from '../../../../src/core/config/remote/components/index.js'; +import {SoloError} from '../../../../src/core/errors.js'; +import {ConsensusNodeStates} from '../../../../src/core/config/remote/enumerations.js'; +import type {NodeAliases} from '../../../../src/types/aliases.js'; + +function testBaseComponentData(classComponent: any) { + it('should fail if name is not provided', () => { + const name = ''; + expect(() => new classComponent(name, 'valid', 'valid')).to.throw(SoloError, `Invalid name: ${name}`); + }); + + it('should fail if name is string', () => { + const name = 1; // @ts-ignore + expect(() => new classComponent(name, 'valid', 'valid')).to.throw(SoloError, `Invalid name: ${name}`); + }); + + it('should fail if cluster is not provided', () => { + const cluster = ''; + expect(() => new classComponent('valid', cluster, 'valid')).to.throw(SoloError, `Invalid cluster: ${cluster}`); + }); + + it('should fail if cluster is string', () => { + const cluster = 1; // @ts-ignore + expect(() => new classComponent('valid', cluster, 'valid')).to.throw(SoloError, `Invalid cluster: ${cluster}`); + }); + + it('should fail if namespace is not provided', () => { + const namespace = ''; + expect(() => new classComponent('valid', 'valid', namespace)).to.throw( + SoloError, + `Invalid namespace: ${namespace}`, + ); + }); + + it('should fail if namespace is string', () => { + const namespace = 1; // @ts-ignore + expect(() => new classComponent('valid', 'valid', namespace)).to.throw( + SoloError, + `Invalid namespace: ${namespace}`, + ); + }); + + it('should successfully create ', () => { + new classComponent('valid', 'valid', 'valid'); + }); + + it('should be an instance of BaseComponent', () => { + const component = new classComponent('valid', 'valid', 'valid'); + expect(component).to.be.instanceOf(BaseComponent); + }); + + it('calling toObject() should return a valid data', () => { + const {name, cluster, namespace} = {name: 'name', cluster: 'cluster', namespace: 'namespace'}; + const component = new classComponent(name, cluster, namespace); + expect(component.toObject()).to.deep.equal({name, cluster, namespace}); + }); +} + +describe('HaProxyComponent', () => testBaseComponentData(HaProxyComponent)); + +describe('EnvoyProxyComponent', () => testBaseComponentData(EnvoyProxyComponent)); + +describe('MirrorNodeComponent', () => testBaseComponentData(MirrorNodeComponent)); + +describe('MirrorNodeExplorerComponent', () => testBaseComponentData(MirrorNodeExplorerComponent)); + +describe('RelayComponent', () => { + it('should fail if name is not provided', () => { + const name = ''; + expect(() => new RelayComponent(name, 'valid', 'valid', [])).to.throw(SoloError, `Invalid name: ${name}`); + }); + + it('should fail if name is string', () => { + const name = 1; // @ts-ignore + expect(() => new RelayComponent(name, 'valid', 'valid', [])).to.throw(SoloError, `Invalid name: ${name}`); + }); + + it('should fail if cluster is not provided', () => { + const cluster = ''; + expect(() => new RelayComponent('valid', cluster, 'valid', [])).to.throw(SoloError, `Invalid cluster: ${cluster}`); + }); + + it('should fail if cluster is string', () => { + const cluster = 1; // @ts-ignore + expect(() => new RelayComponent('valid', cluster, 'valid', [])).to.throw(SoloError, `Invalid cluster: ${cluster}`); + }); + + it('should fail if namespace is not provided', () => { + const namespace = ''; + expect(() => new RelayComponent('valid', 'valid', namespace, [])).to.throw( + SoloError, + `Invalid namespace: ${namespace}`, + ); + }); + + it('should fail if namespace is string', () => { + const namespace = 1; // @ts-ignore + expect(() => new RelayComponent('valid', 'valid', namespace, [])).to.throw( + SoloError, + `Invalid namespace: ${namespace}`, + ); + }); + + it('should fail if consensusNodeAliases is not valid', () => { + const consensusNodeAliases = [undefined] as NodeAliases; + expect(() => new RelayComponent('valid', 'valid', 'valid', consensusNodeAliases)).to.throw( + SoloError, + `Invalid consensus node alias: ${consensusNodeAliases[0]}, aliases ${consensusNodeAliases}`, + ); + }); + + it('should fail if consensusNodeAliases is not valid', () => { + const consensusNodeAliases = ['node1', 1] as NodeAliases; + expect(() => new RelayComponent('valid', 'valid', 'valid', consensusNodeAliases)).to.throw( + SoloError, + `Invalid consensus node alias: 1, aliases ${consensusNodeAliases}`, + ); + }); + + it('should successfully create ', () => { + new RelayComponent('valid', 'valid', 'valid'); + }); + + it('should be an instance of BaseComponent', () => { + const component = new RelayComponent('valid', 'valid', 'valid'); + expect(component).to.be.instanceOf(BaseComponent); + }); + + it('calling toObject() should return a valid data', () => { + const {name, cluster, namespace, consensusNodeAliases} = { + name: 'name', + cluster: 'cluster', + namespace: 'namespace', + consensusNodeAliases: ['node1'] as NodeAliases, + }; + + const component = new RelayComponent(name, cluster, namespace, consensusNodeAliases); + expect(component.toObject()).to.deep.equal({name, cluster, namespace, consensusNodeAliases}); + }); +}); + +describe('ConsensusNodeComponent', () => { + it('should fail if name is not provided', () => { + const name = ''; + expect(() => new ConsensusNodeComponent(name, 'valid', 'valid', ConsensusNodeStates.STARTED)).to.throw( + SoloError, + `Invalid name: ${name}`, + ); + }); + + it('should fail if name is string', () => { + const name = 1; // @ts-ignore + expect(() => new ConsensusNodeComponent(name, 'valid', 'valid', ConsensusNodeStates.STARTED)).to.throw( + SoloError, + `Invalid name: ${name}`, + ); + }); + + it('should fail if cluster is not provided', () => { + const cluster = ''; + expect(() => new ConsensusNodeComponent('valid', cluster, 'valid', ConsensusNodeStates.STARTED)).to.throw( + SoloError, + `Invalid cluster: ${cluster}`, + ); + }); + + it('should fail if cluster is string', () => { + const cluster = 1; // @ts-ignore + expect(() => new ConsensusNodeComponent('valid', cluster, 'valid', ConsensusNodeStates.STARTED)).to.throw( + SoloError, + `Invalid cluster: ${cluster}`, + ); + }); + + it('should fail if namespace is not provided', () => { + const namespace = ''; + expect(() => new ConsensusNodeComponent('valid', 'valid', namespace, ConsensusNodeStates.STARTED)).to.throw( + SoloError, + `Invalid namespace: ${namespace}`, + ); + }); + + it('should fail if namespace is string', () => { + const namespace = 1; // @ts-ignore + expect(() => new ConsensusNodeComponent('valid', 'valid', namespace, ConsensusNodeStates.STARTED)).to.throw( + SoloError, + `Invalid namespace: ${namespace}`, + ); + }); + + it('should fail if state is not valid', () => { + const state = 'invalid' as ConsensusNodeStates.STARTED; + expect(() => new ConsensusNodeComponent('valid', 'valid', 'valid', state)).to.throw( + SoloError, + `Invalid consensus node state: ${state}`, + ); + }); + + it('should successfully create ', () => { + new ConsensusNodeComponent('valid', 'valid', 'valid', ConsensusNodeStates.STARTED); + }); + + it('should be an instance of BaseComponent', () => { + const component = new ConsensusNodeComponent('valid', 'valid', 'valid', ConsensusNodeStates.STARTED); + expect(component).to.be.instanceOf(BaseComponent); + }); + + it('calling toObject() should return a valid data', () => { + const {name, cluster, namespace, state} = { + name: 'name', + cluster: 'cluster', + namespace: 'namespace', + state: ConsensusNodeStates.STARTED, + }; + + const component = new ConsensusNodeComponent(name, cluster, namespace, state); + expect(component.toObject()).to.deep.equal({name, cluster, namespace, state}); + }); +}); diff --git a/test/unit/core/remote_config/components_data_wrapper.test.ts b/test/unit/core/remote_config/components_data_wrapper.test.ts new file mode 100644 index 000000000..718e4b1f5 --- /dev/null +++ b/test/unit/core/remote_config/components_data_wrapper.test.ts @@ -0,0 +1,195 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; + +import {ComponentsDataWrapper} from '../../../../src/core/config/remote/components_data_wrapper.js'; +import { + ConsensusNodeComponent, + EnvoyProxyComponent, + HaProxyComponent, + MirrorNodeComponent, + MirrorNodeExplorerComponent, + RelayComponent, +} from '../../../../src/core/config/remote/components/index.js'; +import {ComponentType, ConsensusNodeStates} from '../../../../src/core/config/remote/enumerations.js'; +import {SoloError} from '../../../../src/core/errors.js'; +import type {NodeAliases} from '../../../../src/types/aliases.js'; + +export function createComponentsDataWrapper() { + const serviceName = 'serviceName'; + + const name = 'name'; + const cluster = 'cluster'; + const namespace = 'namespace'; + const state = ConsensusNodeStates.STARTED; + const consensusNodeAliases = ['node1', 'node2'] as NodeAliases; + + const relays = {[serviceName]: new RelayComponent(name, cluster, namespace, consensusNodeAliases)}; + const haProxies = {[serviceName]: new HaProxyComponent(name, cluster, namespace)}; + const mirrorNodes = {[serviceName]: new MirrorNodeComponent(name, cluster, namespace)}; + const envoyProxies = {[serviceName]: new EnvoyProxyComponent(name, cluster, namespace)}; + const consensusNodes = {[serviceName]: new ConsensusNodeComponent(name, cluster, namespace, state)}; + const mirrorNodeExplorers = {[serviceName]: new MirrorNodeExplorerComponent(name, cluster, namespace)}; + + // @ts-ignore + const componentsDataWrapper = new ComponentsDataWrapper( + relays, + haProxies, + mirrorNodes, + envoyProxies, + consensusNodes, + mirrorNodeExplorers, + ); + /* + ? The class after calling the toObject() method + * RELAY: { serviceName: { name: 'name', cluster: 'cluster', namespace: 'namespace' consensusNodeAliases: ['node1', 'node2'] } }, + * HAPROXY: { serviceName: { name: 'name', cluster: 'cluster', namespace: 'namespace' } }, + * MIRROR_NODE: { serviceName: { name: 'name', cluster: 'cluster', namespace: 'namespace' } }, + * ENVOY_PROXY: { serviceName: { name: 'name', cluster: 'cluster', namespace: 'namespace' } }, + * CONSENSUS_NODE: { serviceName: { state: 'started', name: 'name', cluster: 'cluster', namespace: 'namespace'} }, + * MIRROR_NODE_EXPLORER: { serviceName: { name: 'name', cluster: 'cluster', namespace: 'namespace' } }, + */ + return { + values: {name, cluster, namespace, state, consensusNodeAliases}, + components: {consensusNodes, haProxies, envoyProxies, mirrorNodes, mirrorNodeExplorers, relays}, + wrapper: {componentsDataWrapper}, + serviceName, + }; +} + +describe('ComponentsDataWrapper', () => { + it('should be able to create a instance', () => createComponentsDataWrapper()); + + it('should not be able to create a instance if wrong data is passed to constructor', () => { + // @ts-ignore + expect(() => new ComponentsDataWrapper({serviceName: {}})).to.throw(SoloError, 'Invalid component type'); + }); + + it('toObject method should return a object that can be parsed with fromObject', () => { + const { + wrapper: {componentsDataWrapper}, + } = createComponentsDataWrapper(); + + const newComponentsDataWrapper = ComponentsDataWrapper.fromObject(componentsDataWrapper.toObject()); + const componentsDataWrapperObject = componentsDataWrapper.toObject(); + + expect(componentsDataWrapperObject).to.deep.equal(newComponentsDataWrapper.toObject()); + + Object.values(ComponentType).forEach(type => { + expect(componentsDataWrapperObject).to.have.ownProperty(type); + }); + + expect(componentsDataWrapper); + }); + + it('should not be able to add new component with the .add() method if it already exist', () => { + const { + wrapper: {componentsDataWrapper}, + components: {consensusNodes}, + serviceName, + } = createComponentsDataWrapper(); + + const existingComponent = consensusNodes[serviceName]; + + expect(() => componentsDataWrapper.add(serviceName, existingComponent)).to.throw(SoloError, 'Component exists'); + }); + + it('should be able to add new component with the .add() method', () => { + const { + wrapper: {componentsDataWrapper}, + } = createComponentsDataWrapper(); + + const newServiceName = 'newServiceName'; + const {name, cluster, namespace} = {name: 'envoy', cluster: 'cluster', namespace: 'newNamespace'}; + const newComponent = new EnvoyProxyComponent(name, cluster, namespace); + + componentsDataWrapper.add(newServiceName, newComponent); + + const componentDataWrapperObject = componentsDataWrapper.toObject(); + + expect(componentDataWrapperObject[ComponentType.EnvoyProxy]).has.own.property(newServiceName); + + expect(componentDataWrapperObject[ComponentType.EnvoyProxy][newServiceName]).to.deep.equal({ + name, + cluster, + namespace, + }); + + expect(Object.values(componentDataWrapperObject[ComponentType.EnvoyProxy])).to.have.lengthOf(2); + }); + + it('should be able to edit component with the .edit()', () => { + const { + wrapper: {componentsDataWrapper}, + components: {relays}, + values: {cluster, namespace}, + serviceName, + } = createComponentsDataWrapper(); + const relayComponent = relays[serviceName]; + + componentsDataWrapper.edit(serviceName, relayComponent); + + const newName = 'newName'; + + const newReplayComponent = new RelayComponent(newName, cluster, namespace); + + componentsDataWrapper.edit(serviceName, newReplayComponent); + + expect(componentsDataWrapper.toObject()[ComponentType.Relay][serviceName].name).to.equal(newName); + }); + + it("should not be able to edit component with the .edit() if it doesn't exist ", () => { + const { + wrapper: {componentsDataWrapper}, + components: {relays}, + serviceName, + } = createComponentsDataWrapper(); + const notFoundServiceName = 'not_found'; + const relay = relays[serviceName]; + + expect(() => componentsDataWrapper.edit(notFoundServiceName, relay)).to.throw( + SoloError, + `Component doesn't exist, name: ${notFoundServiceName}`, + ); + }); + + it('should be able to remove component with the .remove()', () => { + const { + wrapper: {componentsDataWrapper}, + serviceName, + } = createComponentsDataWrapper(); + + componentsDataWrapper.remove(serviceName, ComponentType.Relay); + + // @ts-ignore + expect(componentsDataWrapper.relays).not.to.have.own.property(serviceName); + }); + + it("should not be able to remove component with the .remove() if it doesn't exist ", () => { + const { + wrapper: {componentsDataWrapper}, + } = createComponentsDataWrapper(); + + const notFoundServiceName = 'not_found'; + + expect(() => componentsDataWrapper.remove(notFoundServiceName, ComponentType.Relay)).to.throw( + SoloError, + `Component ${notFoundServiceName} of type ${ComponentType.Relay} not found while attempting to remove`, + ); + }); +}); diff --git a/test/unit/core/remote_config/metadata.test.ts b/test/unit/core/remote_config/metadata.test.ts new file mode 100644 index 000000000..c6a482ee4 --- /dev/null +++ b/test/unit/core/remote_config/metadata.test.ts @@ -0,0 +1,128 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; +import {Migration} from '../../../../src/core/config/remote/migration.js'; +import {SoloError} from '../../../../src/core/errors.js'; +import {RemoteConfigMetadata} from '../../../../src/core/config/remote/metadata.js'; +import type {EmailAddress, Namespace} from '../../../../src/core/config/remote/types.js'; + +export function createMetadata() { + const name: Namespace = 'namespace'; + const lastUpdatedAt: Date = new Date(); + const lastUpdateBy: EmailAddress = 'test@test.test'; + const migration = new Migration(lastUpdatedAt, lastUpdateBy, '0.0.0'); + + return { + metadata: new RemoteConfigMetadata(name, lastUpdatedAt, lastUpdateBy, migration), + values: {name, lastUpdatedAt, lastUpdateBy, migration}, + migration, + }; +} + +describe('RemoteConfigMetadata', () => { + it('should be able to create new instance of the class with valid data', () => { + expect(() => createMetadata()).not.to.throw(); + }); + + it('toObject method should return a valid object', () => { + const { + metadata, + migration, + values: {name, lastUpdatedAt, lastUpdateBy}, + } = createMetadata(); + + expect(metadata.toObject()).to.deep.equal({name, lastUpdatedAt, lastUpdateBy, migration: migration.toObject()}); + }); + + it('should successfully create instance using fromObject', () => { + const { + metadata, + values: {name, lastUpdatedAt, lastUpdateBy}, + } = createMetadata(); + + // @ts-ignore + delete metadata._migration; + + const newMetadata = RemoteConfigMetadata.fromObject({name, lastUpdatedAt, lastUpdateBy}); + + expect(newMetadata.toObject()).to.deep.equal(metadata.toObject()); + + expect(() => RemoteConfigMetadata.fromObject(metadata.toObject())).not.to.throw(); + }); + + it('should successfully make migration with makeMigration()', () => { + const { + metadata, + values: {lastUpdateBy}, + } = createMetadata(); + const version = '0.0.1'; + + metadata.makeMigration(lastUpdateBy, version); + + expect(metadata.migration).to.be.ok; + expect(metadata.migration.fromVersion).to.equal(version); + expect(metadata.migration).to.be.instanceof(Migration); + }); + + describe('Values', () => { + const { + values: {name, lastUpdatedAt, lastUpdateBy}, + } = createMetadata(); + + it('should not be able to create new instance of the class with invalid name', () => { + // @ts-ignore + expect(() => new RemoteConfigMetadata(null, lastUpdatedAt, lastUpdateBy)).to.throw( + SoloError, + `Invalid name: ${null}`, + ); + + // @ts-ignore + expect(() => new RemoteConfigMetadata(1, lastUpdatedAt, lastUpdateBy)).to.throw(SoloError, `Invalid name: ${1}`); + }); + + it('should not be able to create new instance of the class with invalid lastUpdatedAt', () => { + // @ts-ignore + expect(() => new RemoteConfigMetadata(name, null, lastUpdateBy)).to.throw( + SoloError, + `Invalid lastUpdatedAt: ${null}`, + ); + + // @ts-ignore + expect(() => new RemoteConfigMetadata(name, 1, lastUpdateBy)).to.throw(SoloError, `Invalid lastUpdatedAt: ${1}`); + }); + + it('should not be able to create new instance of the class with invalid lastUpdateBy', () => { + // @ts-ignore + expect(() => new RemoteConfigMetadata(name, lastUpdatedAt, null)).to.throw( + SoloError, + `Invalid lastUpdateBy: ${null}`, + ); + + // @ts-ignore + expect(() => new RemoteConfigMetadata(name, lastUpdatedAt, 1)).to.throw(SoloError, `Invalid lastUpdateBy: ${1}`); + }); + + it('should not be able to create new instance of the class with invalid migration', () => { + // @ts-ignore + expect(() => new RemoteConfigMetadata(name, lastUpdatedAt, lastUpdateBy, {})).to.throw( + SoloError, + `Invalid migration: ${{}}`, + ); + }); + }); +}); diff --git a/test/unit/core/remote_config/migration.test.ts b/test/unit/core/remote_config/migration.test.ts new file mode 100644 index 000000000..bd66d49b7 --- /dev/null +++ b/test/unit/core/remote_config/migration.test.ts @@ -0,0 +1,73 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; +import {Migration} from '../../../../src/core/config/remote/migration.js'; +import type {EmailAddress, Version} from '../../../../src/core/config/remote/types.js'; +import {SoloError} from '../../../../src/core/errors.js'; + +function createMigration() { + const migratedAt = new Date(); + const migratedBy = 'test@test.test' as EmailAddress; + const fromVersion = '1.0.0' as Version; + + return { + migration: new Migration(migratedAt, migratedBy, fromVersion), + values: {migratedAt, migratedBy, fromVersion}, + }; +} +describe('Migration', () => { + it('should be able to create new instance of the class with valid data', () => { + expect(() => createMigration()).not.to.throw(); + }); + + it('toObject method should return a valid object', () => { + const {migration, values} = createMigration(); + + expect(migration.toObject()).to.deep.equal(values); + }); + + describe('Values', () => { + const migratedAt = new Date(); + const migratedBy = 'test@test.test' as EmailAddress; + const fromVersion = '1.0.0' as Version; + + it('should not be able to create new instance of the class with invalid migratedAt', () => { + // @ts-ignore + expect(() => new Migration(null, migratedBy, fromVersion)).to.throw(SoloError, `Invalid migratedAt: ${null}`); + + // @ts-ignore + expect(() => new Migration(1, migratedBy, fromVersion)).to.throw(SoloError, `Invalid migratedAt: ${1}`); + }); + + it('should not be able to create new instance of the class with invalid migratedBy', () => { + // @ts-ignore + expect(() => new Migration(migratedAt, null, fromVersion)).to.throw(SoloError, `Invalid migratedBy: ${null}`); + + // @ts-ignore + expect(() => new Migration(migratedAt, 1, fromVersion)).to.throw(SoloError, `Invalid migratedBy: ${1}`); + }); + + it('should not be able to create new instance of the class with invalid fromVersion', () => { + // @ts-ignore + expect(() => new Migration(migratedAt, migratedBy, null)).to.throw(SoloError, `Invalid fromVersion: ${null}`); + + // @ts-ignore + expect(() => new Migration(migratedAt, migratedBy, 1)).to.throw(SoloError, `Invalid fromVersion: ${1}`); + }); + }); +}); diff --git a/test/unit/core/remote_config/remote_config_data_wrapper.test.ts b/test/unit/core/remote_config/remote_config_data_wrapper.test.ts new file mode 100644 index 000000000..e1f783295 --- /dev/null +++ b/test/unit/core/remote_config/remote_config_data_wrapper.test.ts @@ -0,0 +1,106 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; + +import * as yaml from 'yaml'; +import {RemoteConfigDataWrapper} from '../../../../src/core/config/remote/remote_config_data_wrapper.js'; +import {createMetadata} from './metadata.test.js'; +import {createComponentsDataWrapper} from './components_data_wrapper.test.js'; +import {SoloError} from '../../../../src/core/errors.js'; +import * as constants from '../../../../src/core/constants.js'; + +function createRemoteConfigDataWrapper() { + const {metadata} = createMetadata(); + const { + wrapper: {componentsDataWrapper}, + } = createComponentsDataWrapper(); + + const clusters = {}; + const components = componentsDataWrapper; + const lastExecutedCommand = 'lastExecutedCommand'; + const commandHistory = []; + + const dataWrapper = new RemoteConfigDataWrapper({ + metadata, + clusters, + components, + lastExecutedCommand, + commandHistory, + }); + + return { + dataWrapper, + values: {metadata, clusters, components, lastExecutedCommand, commandHistory}, + }; +} + +describe('RemoteConfigDataWrapper', () => { + it('should be able to create a instance', () => createRemoteConfigDataWrapper()); + + it('should be able to add new command to history with addCommandToHistory()', () => { + const {dataWrapper} = createRemoteConfigDataWrapper(); + + const command = 'command'; + + dataWrapper.addCommandToHistory(command); + + expect(dataWrapper.lastExecutedCommand).to.equal(command); + expect(dataWrapper.commandHistory).to.include(command); + + it('should be able to handle overflow', () => { + for (let i = 0; i < constants.SOLO_REMOTE_CONFIG_MAX_COMMAND_IN_HISTORY; i++) { + dataWrapper.addCommandToHistory(command); + } + }); + }); + + it('should successfully be able to parse yaml and create instance with fromConfigmap()', () => { + const {dataWrapper} = createRemoteConfigDataWrapper(); + const dataWrapperObject = dataWrapper.toObject(); + + const yamlData = yaml.stringify({ + metadata: dataWrapperObject.metadata, + components: dataWrapperObject.components as any, + clusters: dataWrapperObject.clusters, + commandHistory: dataWrapperObject.commandHistory, + lastExecutedCommand: dataWrapperObject.lastExecutedCommand, + }); + + // @ts-ignore + RemoteConfigDataWrapper.fromConfigmap({data: {'remote-config-data': yamlData}}); + }); + + it('should fail if invalid data is passed to setters', () => { + const {dataWrapper} = createRemoteConfigDataWrapper(); + + // @ts-ignore + expect(() => (dataWrapper.commandHistory = '')).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.lastExecutedCommand = '')).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.lastExecutedCommand = 1)).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.clusters = 1)).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.clusters = '')).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.components = 1)).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.components = '')).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.metadata = null)).to.throw(SoloError); // @ts-ignore + expect(() => (dataWrapper.metadata = {})).to.throw(SoloError); // @ts-ignore + + expect(() => (dataWrapper.clusters = {null: null})).to.throw(SoloError); + expect(() => (dataWrapper.clusters = {namespace: null})).to.throw(SoloError); + expect(() => (dataWrapper.clusters = {null: 'namespace'})).to.throw(SoloError); + }); +});