diff --git a/README.md b/README.md index 7d4ce6738..5e80e2856 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ An opinionated CLI tool to deploy and manage standalone test networks. ### Hardware Requirements -To run a three-node network, you will need to set up Docker Desktop with at least 8GB of memory and 4 CPUs. +To run a three-node network, you will need to set up Docker Desktop with at least 8GB of memory and 4 CPUs. ![alt text](/docs/content/User/DockerDesktop.png) diff --git a/docs/content/User/Env.md b/docs/content/User/Env.md index 8a6b5f35e..65ea18411 100644 --- a/docs/content/User/Env.md +++ b/docs/content/User/Env.md @@ -35,10 +35,3 @@ User can configure the following environment variables to customize the behavior | `RELAY_PODS_READY_MAX_ATTEMPTS` | The maximum number of attempts to check if relay pods are ready. | `100` | | `RELAY_PODS_READY_DELAY` | The interval between attempts to check if relay pods are ready, in the unit of milliseconds. | `120` | | `NETWORK_DESTROY_WAIT_TIMEOUT` | The period of time to wait for network to be destroyed, in the unit of milliseconds. | `60000` | - - - - - - - diff --git a/docs/content/User/FAQ.md b/docs/content/User/FAQ.md index d10869d58..9824d48f1 100644 --- a/docs/content/User/FAQ.md +++ b/docs/content/User/FAQ.md @@ -4,11 +4,12 @@ You can run `solo account init` anytime after `solo node start` ### Where can I find the default account keys ? -It is the well known default genesis key [Link](https://github.com/hashgraph/hedera-services/blob/develop/hedera-node/data/onboard/GenesisPrivKey.txt) +It is the well known default genesis key [Link](https://github.com/hashgraph/hedera-services/blob/develop/hedera-node/data/onboard/GenesisPrivKey.txt) ### How do I get the key for an account? Use the following command to get account balance and private key of the account `0.0.1007`: + ```bash # get account info of 0.0.1007 and also show the private key solo account get --account-id 0.0.1007 -n solo-e2e --private-key diff --git a/docs/content/User/GetStarted.md b/docs/content/User/GetStarted.md index 297936e07..90ef4926a 100644 --- a/docs/content/User/GetStarted.md +++ b/docs/content/User/GetStarted.md @@ -21,6 +21,7 @@ For Hedera extended users: * [Using Environment Variables](Env.md) FAQ: + * [Frequently Asked Questions](FAQ.md) For curious mind: diff --git a/src/commands/base.ts b/src/commands/base.ts index 2b26a13ef..c83d24864 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -96,6 +96,10 @@ export abstract class BaseCommand extends ShellRunner { return valuesArg; } + getConfigManager(): ConfigManager { + return this.configManager; + } + /** * Dynamically builds a class with properties from the provided list of flags * and extra properties, will keep track of which properties are used. Call @@ -185,6 +189,10 @@ export abstract class BaseCommand extends ShellRunner { return this.localConfig; } + getRemoteConfigManager() { + return this.remoteConfigManager; + } + abstract close(): Promise; commandActionBuilder(actionTasks: any, options: any, errorString: string, lease: Lease | null) { diff --git a/src/commands/context/configs.ts b/src/commands/context/configs.ts new file mode 100644 index 000000000..6a60f41fe --- /dev/null +++ b/src/commands/context/configs.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. + * + */ + +import {type NodeAlias} from '../../types/aliases.js'; + +export const CONNECT_CONFIGS_NAME = 'connectConfig'; + +export const connectConfigBuilder = async function (argv, ctx, task) { + const config = this.getConfig(CONNECT_CONFIGS_NAME, argv.flags, [ + 'currentDeploymentName', + ]) as ContextConnectConfigClass; + + // set config in the context for later tasks to use + ctx.config = config; + + return ctx.config; +}; + +export interface ContextConnectConfigClass { + app: string; + cacheDir: string; + devMode: boolean; + namespace: string; + nodeAlias: NodeAlias; + context: string; + clusterName: string; +} diff --git a/src/commands/context/flags.ts b/src/commands/context/flags.ts index e7051fd07..2dc606063 100644 --- a/src/commands/context/flags.ts +++ b/src/commands/context/flags.ts @@ -20,5 +20,12 @@ import {Flags as flags} from '../flags.js'; export const USE_FLAGS = { requiredFlags: [], requiredFlagsWithDisabledPrompt: [], - optionalFlags: [flags.devMode, flags.quiet, flags.clusterName, flags.context, flags.force, flags.namespace], + optionalFlags: [ + flags.devMode, + flags.quiet, + flags.clusterName, + flags.context, + flags.namespace, + flags.userEmailAddress, + ], }; diff --git a/src/commands/context/handlers.ts b/src/commands/context/handlers.ts index e28958836..3c183ca08 100644 --- a/src/commands/context/handlers.ts +++ b/src/commands/context/handlers.ts @@ -19,14 +19,21 @@ import {type ContextCommandTasks} from './tasks.js'; import * as helpers from '../../core/helpers.js'; import * as constants from '../../core/constants.js'; import * as ContextFlags from './flags.js'; +import {RemoteConfigTasks} from '../../core/config/remote/remote_config_tasks.js'; +import type {RemoteConfigManager} from '../../core/config/remote/remote_config_manager.js'; +import {connectConfigBuilder} from './configs.js'; export class ContextCommandHandlers implements CommandHandlers { readonly parent: BaseCommand; readonly tasks: ContextCommandTasks; + public readonly remoteConfigManager: RemoteConfigManager; + private getConfig: any; - constructor(parent: BaseCommand, tasks: ContextCommandTasks) { + constructor(parent: BaseCommand, tasks: ContextCommandTasks, remoteConfigManager: RemoteConfigManager) { this.parent = parent; this.tasks = tasks; + this.remoteConfigManager = remoteConfigManager; + this.getConfig = parent.getConfig.bind(parent); } async connect(argv: any) { @@ -34,8 +41,10 @@ export class ContextCommandHandlers implements CommandHandlers { const action = this.parent.commandActionBuilder( [ - this.tasks.initialize(argv), - this.parent.getLocalConfig().promptLocalConfigTask(), + this.tasks.initialize(argv, connectConfigBuilder.bind(this)), + this.parent.getLocalConfig().promptLocalConfigTask(this.parent.getK8()), + this.tasks.selectContext(argv), + RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), this.tasks.updateLocalConfig(argv), ], { diff --git a/src/commands/context/index.ts b/src/commands/context/index.ts index cf3f5162c..54fee60ad 100644 --- a/src/commands/context/index.ts +++ b/src/commands/context/index.ts @@ -31,7 +31,7 @@ export class ContextCommand extends BaseCommand { constructor(opts: Opts) { super(opts); - this.handlers = new ContextCommandHandlers(this, new ContextCommandTasks(this)); + this.handlers = new ContextCommandHandlers(this, new ContextCommandTasks(this), this.remoteConfigManager); } getCommandDefinition() { diff --git a/src/commands/context/tasks.ts b/src/commands/context/tasks.ts index de5ce8bba..e8474931c 100644 --- a/src/commands/context/tasks.ts +++ b/src/commands/context/tasks.ts @@ -15,10 +15,11 @@ * */ import {Task} from '../../core/task.js'; -import {Templates} from '../../core/templates.js'; import {Flags as flags} from '../flags.js'; import type {ListrTaskWrapper} from 'listr2'; +import type {ConfigBuilder} from '../../types/aliases.js'; import {type BaseCommand} from '../base.js'; +import {splitFlagInput} from '../../core/helpers.js'; export class ContextCommandTasks { private readonly parent: BaseCommand; @@ -29,62 +30,150 @@ export class ContextCommandTasks { updateLocalConfig(argv) { return new Task('Update local configuration', async (ctx: any, task: ListrTaskWrapper) => { - this.parent.logger.info('Updating local configuration...'); + this.parent.logger.info('Compare local and remote configuration...'); + const configManager = this.parent.getConfigManager(); + const isQuiet = configManager.getFlag(flags.quiet); - const isQuiet = !!argv[flags.quiet.name]; + await this.parent.getRemoteConfigManager().modify(async remoteConfig => { + // Update current deployment with cluster list from remoteConfig + const localConfig = this.parent.getLocalConfig(); + const localDeployments = localConfig.deployments; + const remoteClusterList = []; + for (const cluster of Object.keys(remoteConfig.clusters)) { + if (localConfig.currentDeploymentName === remoteConfig.clusters[cluster]) { + remoteClusterList.push(cluster); + } + } + ctx.config.clusters = remoteClusterList; + localDeployments[localConfig.currentDeploymentName].clusters = ctx.config.clusters; + localConfig.setDeployments(localDeployments); - let currentDeploymentName = argv[flags.namespace.name]; - let clusters = Templates.parseClusterAliases(argv[flags.clusterName.name]); - let contextName = argv[flags.context.name]; + const contexts = splitFlagInput(configManager.getFlag(flags.context)); - const kubeContexts = await this.parent.getK8().getContexts(); + for (let i = 0; i < ctx.config.clusters.length; i++) { + const cluster = ctx.config.clusters[i]; + const context = contexts[i]; - if (isQuiet) { - const currentCluster = await this.parent.getK8().getKubeConfig().getCurrentCluster(); - if (!clusters.length) clusters = [currentCluster.name]; - if (!contextName) contextName = await this.parent.getK8().getKubeConfig().getCurrentContext(); + // If a context is provided use it to update the mapping + if (context) { + localConfig.clusterContextMapping[cluster] = context; + } else if (!localConfig.clusterContextMapping[cluster]) { + // In quiet mode use the currently selected context to update the mapping + if (isQuiet) { + localConfig.clusterContextMapping[cluster] = this.parent.getK8().getKubeConfig().getCurrentContext(); + } - if (!currentDeploymentName) { - const selectedContext = kubeContexts.find(e => e.name === contextName); - currentDeploymentName = selectedContext && selectedContext.namespace ? selectedContext.namespace : 'default'; - } - } else { - if (!clusters.length) { - const prompt = flags.clusterName.prompt; - const unparsedClusterAliases = await prompt(task, clusters); - clusters = Templates.parseClusterAliases(unparsedClusterAliases); - } - if (!contextName) { - const prompt = flags.context.prompt; - contextName = await prompt( - task, - kubeContexts.map(c => c.name), - ); - } - if (!currentDeploymentName) { - const prompt = flags.namespace.prompt; - currentDeploymentName = await prompt(task, currentDeploymentName); + // Prompt the user to select a context if mapping value is missing + else { + localConfig.clusterContextMapping[cluster] = await this.promptForContext(task, cluster); + } + } } + this.parent.logger.info('Update local configuration...'); + await localConfig.write(); + }); + }); + } + + private async getSelectedContext(task, selectedCluster, localConfig, isQuiet) { + let selectedContext; + if (isQuiet) { + selectedContext = this.parent.getK8().getKubeConfig().getCurrentContext(); + } else { + selectedContext = await this.promptForContext(task, selectedCluster); + localConfig.clusterContextMapping[selectedCluster] = selectedContext; + } + return selectedContext; + } + + private async promptForContext(task, cluster) { + const kubeContexts = this.parent.getK8().getContexts(); + return flags.context.prompt( + task, + kubeContexts.map(c => c.name), + cluster, + ); + } + + private async selectContextForFirstCluster(task, clusters, localConfig, isQuiet) { + const selectedCluster = clusters[0]; + + if (localConfig.clusterContextMapping[selectedCluster]) { + return localConfig.clusterContextMapping[selectedCluster]; + } + + // If cluster does not exist in LocalConfig mapping prompt the user to select a context or use the current one + else { + return this.getSelectedContext(task, selectedCluster, localConfig, isQuiet); + } + } + + selectContext(argv) { + return new Task('Read local configuration settings', async (ctx: any, task: ListrTaskWrapper) => { + this.parent.logger.info('Read local configuration settings...'); + const configManager = this.parent.getConfigManager(); + const isQuiet = configManager.getFlag(flags.quiet); + const deploymentName: string = configManager.getFlag(flags.namespace); + let clusters = splitFlagInput(configManager.getFlag(flags.clusterName)); + const contexts = splitFlagInput(configManager.getFlag(flags.context)); + const localConfig = this.parent.getLocalConfig(); + let selectedContext; + + // If one or more contexts are provided use the first one + if (contexts.length) { + selectedContext = contexts[0]; } - // Select current deployment - this.parent.getLocalConfig().setCurrentDeployment(currentDeploymentName); + // If one or more clusters are provided use the first one to determine the context + // from the mapping in the LocalConfig + else if (clusters.length) { + selectedContext = await this.selectContextForFirstCluster(task, clusters, localConfig, isQuiet); + } + + // If a deployment name is provided get the clusters associated with the deployment from the LocalConfig + // and select the context from the mapping, corresponding to the first deployment cluster + else if (deploymentName) { + const deployment = localConfig.deployments[deploymentName]; + + if (deployment && deployment.clusters.length) { + selectedContext = await this.selectContextForFirstCluster(task, deployment.clusters, localConfig, isQuiet); + } - // Set clusters for active deployment - const deployments = this.parent.getLocalConfig().deployments; - deployments[currentDeploymentName].clusters = clusters; - this.parent.getLocalConfig().setDeployments(deployments); + // The provided deployment does not exist in the LocalConfig + else { + // Add the deployment to the LocalConfig with the currently selected cluster and context in KubeConfig + if (isQuiet) { + selectedContext = this.parent.getK8().getKubeConfig().getCurrentContext(); + const selectedCluster = this.parent.getK8().getKubeConfig().getCurrentCluster().name; + localConfig.deployments[deploymentName] = { + clusters: [selectedCluster], + }; - this.parent.getK8().getKubeConfig().setCurrentContext(contextName); + if (!localConfig.clusterContextMapping[selectedCluster]) { + localConfig.clusterContextMapping[selectedCluster] = selectedContext; + } + } - this.parent.logger.info( - `Save LocalConfig file: [currentDeploymentName: ${currentDeploymentName}, contextName: ${contextName}, clusters: ${clusters.join(' ')}]`, - ); - await this.parent.getLocalConfig().write(); + // Prompt user for clusters and contexts + else { + clusters = splitFlagInput(await flags.clusterName.prompt(task, clusters)); + + for (const cluster of clusters) { + if (!localConfig.clusterContextMapping[cluster]) { + localConfig.clusterContextMapping[cluster] = await this.promptForContext(task, cluster); + } + } + + selectedContext = localConfig.clusterContextMapping[clusters[0]]; + } + } + } + + this.parent.getK8().getKubeConfig().setCurrentContext(selectedContext); }); } - initialize(argv: any) { + initialize(argv: any, configInit: ConfigBuilder) { const {requiredFlags, optionalFlags} = argv; argv.flags = [...requiredFlags, ...optionalFlags]; @@ -93,6 +182,8 @@ export class ContextCommandTasks { if (argv[flags.devMode.name]) { this.parent.logger.setDevMode(true); } + + ctx.config = await configInit(argv, ctx, task); }); } } diff --git a/src/commands/deployment.ts b/src/commands/deployment.ts index 67ac83a02..5751994e9 100644 --- a/src/commands/deployment.ts +++ b/src/commands/deployment.ts @@ -80,7 +80,7 @@ export class DeploymentCommand extends BaseCommand { return ListrLease.newAcquireLeaseTask(lease, task); }, }, - this.localConfig.promptLocalConfigTask(), + this.localConfig.promptLocalConfigTask(self.k8), { title: 'Validate cluster connections', task: async (ctx, task): Promise> => { diff --git a/src/commands/flags.ts b/src/commands/flags.ts index 23adfdfd9..7aeb065ef 100644 --- a/src/commands/flags.ts +++ b/src/commands/flags.ts @@ -1413,15 +1413,15 @@ export class Flags { constName: 'contextName', name: 'context', definition: { - describe: 'The Kubernetes context name to be used', + describe: 'The Kubernetes context name to be used. Multiple contexts can be separated by a comma', defaultValue: '', type: 'string', }, - prompt: async function promptContext(task: ListrTaskWrapper, input: string[]) { + prompt: async function promptContext(task: ListrTaskWrapper, input: string[], cluster?: string) { return await task.prompt(ListrEnquirerPromptAdapter).run({ type: 'select', name: 'context', - message: 'Select kubectl context', + message: 'Select kubectl context' + (cluster ? ` to be associated with cluster: ${cluster}` : ''), choices: input, }); }, diff --git a/src/core/config/local_config.ts b/src/core/config/local_config.ts index 57ff22b2c..a5b5532c5 100644 --- a/src/core/config/local_config.ts +++ b/src/core/config/local_config.ts @@ -19,14 +19,20 @@ import type {ListrTask, ListrTaskWrapper} from 'listr2'; import fs from 'fs'; import * as yaml from 'yaml'; import {Flags as flags} from '../../commands/flags.js'; -import {type Deployments, type DeploymentStructure, type LocalConfigData} from './local_config_data.js'; +import { + type ClusterContextMapping, + type Deployments, + type DeploymentStructure, + type LocalConfigData, +} from './local_config_data.js'; import {MissingArgumentError, SoloError} from '../errors.js'; import {type SoloLogger} from '../logging.js'; -import {IsDeployments} from '../validator_decorators.js'; +import {IsClusterContextMapping, 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'; +import {type K8} from '../k8.js'; +import {splitFlagInput} from '../helpers.js'; export class LocalConfig implements LocalConfigData { @IsEmail( @@ -54,6 +60,12 @@ export class LocalConfig implements LocalConfigData { @IsNotEmpty() currentDeploymentName: string; + @IsClusterContextMapping({ + message: ErrorMessages.LOCAL_CONFIG_CONTEXT_CLUSTER_MAPPING_FORMAT, + }) + @IsNotEmpty() + public clusterContextMapping: ClusterContextMapping = {}; + private readonly skipPromptTask: boolean = false; public constructor( @@ -64,7 +76,7 @@ export class LocalConfig implements LocalConfigData { if (!filePath || filePath === '') throw new MissingArgumentError('a valid filePath is required'); if (!logger) throw new Error('An instance of core/SoloLogger is required'); - const allowedKeys = ['userEmailAddress', 'deployments', 'currentDeploymentName']; + const allowedKeys = ['userEmailAddress', 'deployments', 'currentDeploymentName', 'clusterContextMapping']; if (this.configFileExists()) { const fileContent = fs.readFileSync(filePath, 'utf8'); const parsedConfig = yaml.parse(fileContent); @@ -118,6 +130,12 @@ export class LocalConfig implements LocalConfigData { return this; } + public setClusterContextMapping(clusterContextMapping: ClusterContextMapping): this { + this.clusterContextMapping = clusterContextMapping; + this.validate(); + return this; + } + public getCurrentDeployment(): DeploymentStructure { return this.deployments[this.currentDeploymentName]; } @@ -131,36 +149,84 @@ export class LocalConfig implements LocalConfigData { userEmailAddress: this.userEmailAddress, deployments: this.deployments, currentDeploymentName: this.currentDeploymentName, + clusterContextMapping: this.clusterContextMapping, }); await fs.promises.writeFile(this.filePath, yamlContent); - this.logger.info(`Wrote local config to ${this.filePath}`); + + this.logger.info(`Wrote local config to ${this.filePath}: ${yamlContent}`); } - public promptLocalConfigTask(): ListrTask { + public promptLocalConfigTask(k8: K8): ListrTask { const self = this; return { title: 'Prompt local configuration', skip: this.skipPromptTask, task: async (_: any, task: ListrTaskWrapper): Promise => { + const isQuiet = self.configManager.getFlag(flags.quiet); + const contexts = self.configManager.getFlag(flags.context); + const deploymentName = self.configManager.getFlag(flags.namespace); let userEmailAddress = self.configManager.getFlag(flags.userEmailAddress); - if (!userEmailAddress) userEmailAddress = await flags.userEmailAddress.prompt(task, userEmailAddress); + let deploymentClusters: string = self.configManager.getFlag(flags.deploymentClusters); + + if (!userEmailAddress) { + if (isQuiet) throw new SoloError(ErrorMessages.LOCAL_CONFIG_INVALID_EMAIL); + userEmailAddress = await flags.userEmailAddress.prompt(task, userEmailAddress); + self.configManager.setFlag(flags.userEmailAddress, userEmailAddress); + } - const deploymentName = self.configManager.getFlag(flags.namespace); if (!deploymentName) throw new SoloError('Namespace was not specified'); - let deploymentClusters = self.configManager.getFlag(flags.deploymentClusters); - if (!deploymentClusters) deploymentClusters = await flags.deploymentClusters.prompt(task, deploymentClusters); + if (!deploymentClusters) { + if (isQuiet) { + deploymentClusters = k8.getKubeConfig().getCurrentCluster().name; + } else { + deploymentClusters = await flags.deploymentClusters.prompt(task, deploymentClusters); + } + self.configManager.setFlag(flags.deploymentClusters, deploymentClusters); + } + + const parsedClusters = splitFlagInput(deploymentClusters); const deployments: Deployments = { - [deploymentName]: {clusters: Templates.parseClusterAliases(deploymentClusters)}, + [deploymentName]: {clusters: parsedClusters}, }; - this.userEmailAddress = userEmailAddress; - this.deployments = deployments; - this.currentDeploymentName = deploymentName; - this.validate(); - await this.write(); + const parsedContexts = splitFlagInput(contexts); + + if (parsedContexts.length < parsedClusters.length) { + if (!isQuiet) { + const promptedContexts = []; + for (const cluster of parsedClusters) { + const kubeContexts = k8.getContexts(); + const context = await flags.context.prompt( + task, + kubeContexts.map(c => c.name), + cluster, + ); + self.clusterContextMapping[cluster] = context; + promptedContexts.push(context); + } + self.configManager.setFlag(flags.context, promptedContexts.join(',')); + } else { + const context = k8.getKubeConfig().getCurrentContext(); + for (const cluster of parsedClusters) { + self.clusterContextMapping[cluster] = context; + } + self.configManager.setFlag(flags.context, context); + } + } else { + for (let i = 0; i < parsedClusters.length; i++) { + const cluster = parsedClusters[i]; + self.clusterContextMapping[cluster] = parsedContexts[i]; + } + } + + self.userEmailAddress = userEmailAddress; + self.deployments = deployments; + self.currentDeploymentName = deploymentName; + self.validate(); + await self.write(); }, }; } diff --git a/src/core/config/local_config_data.ts b/src/core/config/local_config_data.ts index 9ea49c518..a9efa3c9d 100644 --- a/src/core/config/local_config_data.ts +++ b/src/core/config/local_config_data.ts @@ -14,16 +14,27 @@ * limitations under the License. * */ -import type {Cluster, EmailAddress, Namespace} from './remote/types.js'; +import type {Cluster, Context, EmailAddress, Namespace} from './remote/types.js'; export interface DeploymentStructure { + // A list of clusters on which the deployment is deployed clusters: Cluster[]; } +export type ClusterContextMapping = Record; + export type Deployments = Record; export interface LocalConfigData { + // Only used to differentiate the current user. Not actually used to send emails userEmailAddress: EmailAddress; + + // A list of all deployments deployments: Deployments; + + // The currently selected deployment currentDeploymentName: Namespace; + + // Every cluster must have a kubectl context associated to it, which is used to establish a connection. + clusterContextMapping: ClusterContextMapping; } diff --git a/src/core/error_messages.ts b/src/core/error_messages.ts index 789fab0b4..d1cf3bfd4 100644 --- a/src/core/error_messages.ts +++ b/src/core/error_messages.ts @@ -21,4 +21,5 @@ export const ErrorMessages = { LOCAL_CONFIG_GENERIC: 'Validation of local config failed', LOCAL_CONFIG_INVALID_EMAIL: 'Invalid email address provided', LOCAL_CONFIG_INVALID_DEPLOYMENTS_FORMAT: 'Wrong deployments format', + LOCAL_CONFIG_CONTEXT_CLUSTER_MAPPING_FORMAT: 'Wrong clusterContextMapping format', }; diff --git a/src/core/helpers.ts b/src/core/helpers.ts index 2c85409f5..cdeab2cf0 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -40,7 +40,9 @@ export function parseNodeAliases(input: string): NodeAliases { } export function splitFlagInput(input: string, separator = ',') { - if (typeof input !== 'string') { + if (!input) { + return []; + } else if (typeof input !== 'string') { throw new SoloError(`input [input='${input}'] is not a comma separated string`); } diff --git a/src/core/templates.ts b/src/core/templates.ts index d21956bb7..17a65eab0 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -255,10 +255,6 @@ export class Templates { return mapping; } - static parseClusterAliases(clusters: string) { - return clusters ? clusters.split(',') : []; - } - public static renderEnvoyProxyName(nodeAlias: NodeAlias): string { return `envoy-proxy-${nodeAlias}`; } diff --git a/src/core/validator_decorators.ts b/src/core/validator_decorators.ts index caaf26e44..33fe5688c 100644 --- a/src/core/validator_decorators.ts +++ b/src/core/validator_decorators.ts @@ -46,3 +46,33 @@ export const IsDeployments = (validationOptions?: ValidationOptions) => { }); }; }; + +export const IsClusterContextMapping = (validationOptions?: ValidationOptions) => { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'IsClusterContextMapping', + target: object.constructor, + propertyName: propertyName, + constraints: [], + options: { + ...validationOptions, + }, + validator: { + validate(value: any, args: ValidationArguments) { + if (!isObject(value)) return false; + if (Object.keys(value).length === 0) return true; + + // TODO expand the validation. Check if the context exists in the local kube config + // and that it can actually establish a connection to the cluster + for (const clusterName in value) { + const contextName = value[clusterName]; + if (typeof clusterName !== 'string' || typeof contextName !== 'string') { + return false; + } + } + return true; + }, + }, + }); + }; +}; diff --git a/src/types/flag_types.ts b/src/types/flag_types.ts index a33616d01..574a33111 100644 --- a/src/types/flag_types.ts +++ b/src/types/flag_types.ts @@ -16,7 +16,7 @@ */ import {type ListrTaskWrapper} from 'listr2'; -export type PromptFunction = (task: ListrTaskWrapper, input: any) => Promise; +export type PromptFunction = (task: ListrTaskWrapper, input: any, data?: any) => Promise; export interface CommandFlag { constName: string; diff --git a/test/data/local-config.yaml b/test/data/local-config.yaml index 83005650b..fdac33ca3 100644 --- a/test/data/local-config.yaml +++ b/test/data/local-config.yaml @@ -4,5 +4,6 @@ deployments: clusterAliases: - cluster-1 currentDeploymentName: deployment-1 -clusterMappings: - kind-node-add: kind-node-add +clusterContextMapping: + cluster-1: context-1 + cluster-2: context-2 \ No newline at end of file diff --git a/test/test_util.ts b/test/test_util.ts index eda7b91c3..68b830e21 100644 --- a/test/test_util.ts +++ b/test/test_util.ts @@ -465,4 +465,8 @@ export const testLocalConfigData = { }, }, currentDeploymentName: 'deployment', + clusterContextMapping: { + 'cluster-1': 'context-1', + 'cluster-2': 'context-2', + }, }; diff --git a/test/unit/commands/context.test.ts b/test/unit/commands/context.test.ts index cfc181ce0..b8381c2eb 100644 --- a/test/unit/commands/context.test.ts +++ b/test/unit/commands/context.test.ts @@ -43,11 +43,25 @@ import {stringify} from 'yaml'; import {type Cluster, KubeConfig} from '@kubernetes/client-node'; import {type ListrTaskWrapper} from 'listr2'; import {ContextCommand} from '../../../src/commands/context/index.js'; +import {type CommandFlag} from '../../../src/types/flag_types.js'; describe('ContextCommandTasks unit tests', () => { const filePath = `${getTestCacheDir('ContextCommandTasks')}/localConfig.yaml`; + const sandbox = sinon.createSandbox(); + let namespacePromptStub: sinon.SinonStub; + let clusterNamePromptStub: sinon.SinonStub; + let contextPromptStub: sinon.SinonStub; + let tasks: ContextCommandTasks; + let command: BaseCommand; + let loggerStub: sinon.SinonStubbedInstance; + let localConfig: LocalConfig; - const getBaseCommandOpts = (sandbox: sinon.SinonSandbox) => { + const getBaseCommandOpts = ( + sandbox: sinon.SinonSandbox, + remoteConfig: any = {}, + // @ts-ignore + stubbedFlags: Record[] = [], + ) => { const loggerStub = sandbox.createStubInstance(SoloLogger); const k8Stub = sandbox.createStubInstance(K8); k8Stub.getContexts.returns([ @@ -56,8 +70,7 @@ describe('ContextCommandTasks unit tests', () => { {cluster: 'cluster-3', user: 'user-3', name: 'context-3', namespace: 'deployment-3'}, ]); const kubeConfigStub = sandbox.createStubInstance(KubeConfig); - kubeConfigStub.getCurrentContext.returns('context-3'); - kubeConfigStub.getCurrentContext.returns('context-3'); + kubeConfigStub.getCurrentContext.returns('context-from-kubeConfig'); kubeConfigStub.getCurrentCluster.returns({ name: 'cluster-3', caData: 'caData', @@ -67,10 +80,19 @@ describe('ContextCommandTasks unit tests', () => { tlsServerName: 'tls-3', } as Cluster); + const remoteConfigManagerStub = sandbox.createStubInstance(RemoteConfigManager); + remoteConfigManagerStub.modify.callsFake(async callback => { + await callback(remoteConfig); + }); + k8Stub.getKubeConfig.returns(kubeConfigStub); const configManager = sandbox.createStubInstance(ConfigManager); + for (let i = 0; i < stubbedFlags.length; i++) { + configManager.getFlag.withArgs(stubbedFlags[i][0]).returns(stubbedFlags[i][1]); + } + return { logger: loggerStub, helm: sandbox.createStubInstance(Helm), @@ -86,23 +108,17 @@ describe('ContextCommandTasks unit tests', () => { profileManager: sandbox.createStubInstance(ProfileManager), leaseManager: sandbox.createStubInstance(LeaseManager), certificateManager: sandbox.createStubInstance(CertificateManager), - remoteConfigManager: sandbox.createStubInstance(RemoteConfigManager), + remoteConfigManager: remoteConfigManagerStub, } as Opts; }; describe('updateLocalConfig', () => { - const sandbox = sinon.createSandbox(); - let namespacePromptStub: sinon.SinonStub; - let clusterNamePromptStub: sinon.SinonStub; - let contextPromptStub: sinon.SinonStub; - let tasks: ContextCommandTasks; - let command: BaseCommand; - let loggerStub: sinon.SinonStubbedInstance; - let localConfig: LocalConfig; - - async function runUpdateLocalConfigTask(argv) { - const taskObj = tasks.updateLocalConfig(argv); - return taskObj.task({}, sandbox.stub() as unknown as ListrTaskWrapper); + async function runUpdateLocalConfigTask(opts) { + command = new ContextCommand(opts); + tasks = new ContextCommandTasks(command); + const taskObj = tasks.updateLocalConfig({}); + await taskObj.task({config: {}}, sandbox.stub() as unknown as ListrTaskWrapper); + return command; } afterEach(async () => { @@ -130,123 +146,216 @@ describe('ContextCommandTasks unit tests', () => { }); loggerStub = sandbox.createStubInstance(SoloLogger); await fs.promises.writeFile(filePath, stringify(testLocalConfigData)); - command = new ContextCommand(getBaseCommandOpts(sandbox)); - tasks = new ContextCommandTasks(command); }); - it('should update local configuration with provided values', async () => { - const argv = { - [flags.namespace.name]: 'deployment-2', - [flags.clusterName.name]: 'cluster-2', - [flags.context.name]: 'context-2', + it('should update currentDeployment with clusters from remoteConfig', async () => { + const remoteConfig = { + clusters: { + 'cluster-2': 'deployment', + }, }; - - await runUpdateLocalConfigTask(argv); // @ts-ignore + const opts = getBaseCommandOpts(sandbox, remoteConfig, []); + command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath, loggerStub, command.configManager); - expect(localConfig.currentDeploymentName).to.equal('deployment-2'); + expect(localConfig.currentDeploymentName).to.equal('deployment'); expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); + expect(localConfig.clusterContextMapping).to.deep.equal({ + 'cluster-1': 'context-1', + 'cluster-2': 'context-2', + }); }); - it('should prompt for all flags if none are provided', async () => { - const argv = {}; - await runUpdateLocalConfigTask(argv); //@ts-ignore + it('should update clusterContextMapping with provided context', async () => { + const remoteConfig = { + clusters: { + 'cluster-2': 'deployment', + }, + }; + const opts = getBaseCommandOpts(sandbox, remoteConfig, [[flags.context, 'provided-context']]); + command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath, loggerStub, command.configManager); - expect(localConfig.currentDeploymentName).to.equal('deployment-3'); - expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-3']); - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); - expect(namespacePromptStub).to.have.been.calledOnce; - expect(clusterNamePromptStub).to.have.been.calledOnce; - expect(contextPromptStub).to.have.been.calledOnce; + expect(localConfig.currentDeploymentName).to.equal('deployment'); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); + expect(localConfig.clusterContextMapping).to.deep.equal({ + 'cluster-1': 'context-1', + 'cluster-2': 'provided-context', + }); }); - it('should prompt for namespace if no value is provided', async () => { - const argv = { - [flags.clusterName.name]: 'cluster-2', - [flags.context.name]: 'context-2', + it('should update multiple clusterContextMappings with provided contexts', async () => { + const remoteConfig = { + clusters: { + 'cluster-2': 'deployment', + 'cluster-3': 'deployment', + 'cluster-4': 'deployment', + }, }; - - await runUpdateLocalConfigTask(argv); // @ts-ignore + const opts = getBaseCommandOpts(sandbox, remoteConfig, [ + [flags.context, 'provided-context-2,provided-context-3,provided-context-4'], + ]); + command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath, loggerStub, command.configManager); - expect(localConfig.currentDeploymentName).to.equal('deployment-3'); - expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); - expect(namespacePromptStub).to.have.been.calledOnce; - expect(clusterNamePromptStub).to.have.been.not.called; - expect(contextPromptStub).to.have.been.not.called; + expect(localConfig.currentDeploymentName).to.equal('deployment'); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2', 'cluster-3', 'cluster-4']); + expect(localConfig.clusterContextMapping).to.deep.equal({ + 'cluster-1': 'context-1', + 'cluster-2': 'provided-context-2', + 'cluster-3': 'provided-context-3', + 'cluster-4': 'provided-context-4', + }); }); - it('should prompt for cluster if no value is provided', async () => { - const argv = { - [flags.namespace.name]: 'deployment-2', - [flags.context.name]: 'context-2', + it('should update multiple clusterContextMappings with default KubeConfig context if quiet=true', async () => { + const remoteConfig = { + clusters: { + 'cluster-2': 'deployment', + 'cluster-3': 'deployment', + }, }; - - await runUpdateLocalConfigTask(argv); // @ts-ignore + const opts = getBaseCommandOpts(sandbox, remoteConfig, [[flags.quiet, true]]); + command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath, loggerStub, command.configManager); - expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-3']); - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); - expect(namespacePromptStub).to.have.been.not.called; - expect(clusterNamePromptStub).to.have.been.calledOnce; - expect(contextPromptStub).to.have.been.not.called; + expect(localConfig.currentDeploymentName).to.equal('deployment'); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2', 'cluster-3']); + expect(localConfig.clusterContextMapping).to.deep.equal({ + 'cluster-1': 'context-1', + 'cluster-2': 'context-2', + 'cluster-3': 'context-from-kubeConfig', + }); }); - it('should prompt for context if no value is provided', async () => { - const argv = { - [flags.namespace.name]: 'deployment-2', - [flags.clusterName.name]: 'cluster-2', + it('should update multiple clusterContextMappings with prompted context no value was provided', async () => { + const remoteConfig = { + clusters: { + 'cluster-2': 'deployment', + 'new-cluster': 'deployment', + }, }; + const opts = getBaseCommandOpts(sandbox, remoteConfig, []); - await runUpdateLocalConfigTask(argv); // @ts-ignore + command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath, loggerStub, command.configManager); - expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); - expect(namespacePromptStub).to.have.been.not.called; - expect(clusterNamePromptStub).to.have.been.not.called; - expect(contextPromptStub).to.have.been.calledOnce; + expect(localConfig.currentDeploymentName).to.equal('deployment'); + expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2', 'new-cluster']); + expect(localConfig.clusterContextMapping).to.deep.equal({ + 'cluster-1': 'context-1', + 'cluster-2': 'context-2', + 'new-cluster': 'context-3', // prompted value + }); }); + }); - it('should use cluster from kubectl if no value is provided and quiet=true', async () => { - const argv = { - [flags.namespace.name]: 'deployment-2', - [flags.context.name]: 'context-2', - [flags.quiet.name]: 'true', - }; + describe('selectContext', () => { + async function runSelectContextTask(opts) { + command = new ContextCommand(opts); + tasks = new ContextCommandTasks(command); + const taskObj = tasks.selectContext({}); + await taskObj.task({config: {}}, sandbox.stub() as unknown as ListrTaskWrapper); + return command; + } - await runUpdateLocalConfigTask(argv); // @ts-ignore - localConfig = new LocalConfig(filePath, loggerStub, command.configManager); + afterEach(async () => { + await fs.promises.unlink(filePath); + sandbox.restore(); + }); - expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-3']); + beforeEach(async () => { + namespacePromptStub = sandbox.stub(flags.namespace, 'prompt').callsFake(() => { + return new Promise(resolve => { + resolve('deployment-3'); + }); + }); + clusterNamePromptStub = sandbox.stub(flags.clusterName, 'prompt').callsFake(() => { + return new Promise(resolve => { + resolve('cluster-3'); + }); + }); + contextPromptStub = sandbox.stub(flags.context, 'prompt').callsFake(() => { + return new Promise(resolve => { + resolve('context-3'); + }); + }); + loggerStub = sandbox.createStubInstance(SoloLogger); + await fs.promises.writeFile(filePath, stringify(testLocalConfigData)); + }); + + it('should use first provided context', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [ + [flags.context, 'provided-context-1,provided-context-2,provided-context-3'], + ]); + + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('provided-context-1'); + }); + + it('should use local config mapping to connect to first provided cluster', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [[flags.clusterName, 'cluster-2,cluster-3']]); + + command = await runSelectContextTask(opts); // @ts-ignore expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); - expect(namespacePromptStub).to.have.been.not.called; - expect(clusterNamePromptStub).to.have.been.not.called; - expect(contextPromptStub).to.have.been.not.called; }); - it('should use namespace from kubectl if no value is provided and quiet=true', async () => { - const argv = { - [flags.clusterName.name]: 'cluster-2', - [flags.context.name]: 'context-2', - [flags.quiet.name]: 'true', - }; + it('should prompt for context if selected cluster is not found in local config mapping', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [[flags.clusterName, 'cluster-3']]); - await runUpdateLocalConfigTask(argv); // @ts-ignore - localConfig = new LocalConfig(filePath, loggerStub, command.configManager); + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); + }); - expect(localConfig.currentDeploymentName).to.equal('deployment-2'); - expect(localConfig.getCurrentDeployment().clusters).to.deep.equal(['cluster-2']); + it('should use default kubeConfig context if selected cluster is not found in local config mapping and quiet=true', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [ + [flags.clusterName, 'unknown-cluster'], + [flags.quiet, true], + ]); + + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); + }); + + it('should use context from local config mapping for the first cluster from the selected deployment', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [[flags.namespace, 'deployment-2']]); + + command = await runSelectContextTask(opts); // @ts-ignore expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-2'); - expect(namespacePromptStub).to.have.been.not.called; - expect(clusterNamePromptStub).to.have.been.not.called; - expect(contextPromptStub).to.have.been.not.called; + }); + + it('should prompt for context if selected deployment is found in local config but the context is not', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [[flags.namespace, 'deployment-3']]); + + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); + }); + + it('should use default context if selected deployment is found in local config but the context is not and quiet=true', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [ + [flags.namespace, 'deployment-3'], + [flags.quiet, true], + ]); + + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); + }); + + it('should prompt for clusters and contexts if selected deployment is not found in local config', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [[flags.namespace, 'deployment-4']]); + + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); + }); + + it('should use clusters and contexts from kubeConfig if selected deployment is not found in local config and quiet=true', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [ + [flags.namespace, 'deployment-4'], + [flags.quiet, true], + ]); + + command = await runSelectContextTask(opts); // @ts-ignore + expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); }); }); }); diff --git a/test/unit/core/local_config.test.ts b/test/unit/core/local_config.test.ts index 141748dbc..4dc78a699 100644 --- a/test/unit/core/local_config.test.ts +++ b/test/unit/core/local_config.test.ts @@ -54,6 +54,7 @@ describe('LocalConfig', () => { expect(localConfig.userEmailAddress).to.eq(config.userEmailAddress); expect(localConfig.deployments).to.deep.eq(config.deployments); expect(localConfig.currentDeploymentName).to.eq(config.currentDeploymentName); + expect(localConfig.clusterContextMapping).to.deep.eq(config.clusterContextMapping); }); it('should set user email address', async () => { @@ -120,15 +121,28 @@ describe('LocalConfig', () => { } }); - it('should not set invalid context mappings', async () => { - const invalidContextMappings = { + it('should set clusterContextMapping', async () => { + const newClusterMappings = { + 'cluster-3': 'context-3', + 'cluster-4': 'context-4', + }; + localConfig.setClusterContextMapping(newClusterMappings); + expect(localConfig.clusterContextMapping).to.eq(newClusterMappings); + + await localConfig.write(); + const newConfig = new LocalConfig(filePath, testLogger, configManager); + expect(newConfig.clusterContextMapping).to.deep.eq(newClusterMappings); + }); + + it('should not set invalid clusterContextMapping', async () => { + const invalidClusterContextMappings = { 'cluster-3': 'context-3', 'invalid-cluster': 5, }; try { // @ts-ignore - localConfig.setContextMappings(invalidContextMappings); + localConfig.setContextMappings(invalidClusterContextMappings); expect.fail('expected an error to be thrown'); } catch (error) { expect(error).to.be.instanceOf(TypeError); @@ -213,6 +227,14 @@ describe('LocalConfig', () => { expectFailedValidation(ErrorMessages.LOCAL_CONFIG_INVALID_DEPLOYMENTS_FORMAT); }); + it('should throw a validation error if clusterContextMapping format is not correct', async () => { + await fs.promises.writeFile(filePath, stringify({...config, clusterContextMapping: 'foo'})); + expectFailedValidation(ErrorMessages.LOCAL_CONFIG_CONTEXT_CLUSTER_MAPPING_FORMAT); + + await fs.promises.writeFile(filePath, stringify({...config, clusterContextMapping: ['foo', 5]})); + expectFailedValidation(ErrorMessages.LOCAL_CONFIG_CONTEXT_CLUSTER_MAPPING_FORMAT); + }); + it('should throw a validation error if currentDeploymentName format is not correct', async () => { await fs.promises.writeFile(filePath, stringify({...config, currentDeploymentName: 5})); expectFailedValidation(ErrorMessages.LOCAL_CONFIG_CURRENT_DEPLOYMENT_DOES_NOT_EXIST);