diff --git a/src/commands/base.ts b/src/commands/base.ts index a49241663..8b2a7860f 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -33,6 +33,7 @@ import {Listr} from 'listr2'; import path from 'path'; import * as constants from '../core/constants.js'; import fs from 'fs'; +import {Task} from '../core/task.js'; export interface CommandHandlers { parent: BaseCommand; @@ -251,4 +252,10 @@ export abstract class BaseCommand extends ShellRunner { return dirs; } + + setupHomeDirectoryTask() { + return new Task('Setup home directory', async () => { + this.setupHomeDirectory(); + }); + } } diff --git a/src/commands/cluster/handlers.ts b/src/commands/cluster/handlers.ts index b1dd0b6c6..924edd723 100644 --- a/src/commands/cluster/handlers.ts +++ b/src/commands/cluster/handlers.ts @@ -43,10 +43,11 @@ export class ClusterCommandHandlers implements CommandHandlers { const action = this.parent.commandActionBuilder( [ this.tasks.initialize(argv, connectConfigBuilder.bind(this)), - this.tasks.setupHomeDirectory(), + this.parent.setupHomeDirectoryTask(), this.parent.getLocalConfig().promptLocalConfigTask(this.parent.getK8()), this.tasks.selectContext(argv), RemoteConfigTasks.loadRemoteConfig.bind(this)(argv), + this.tasks.readClustersFromRemoteConfig(argv), this.tasks.updateLocalConfig(argv), ], { diff --git a/src/commands/cluster/tasks.ts b/src/commands/cluster/tasks.ts index fca930eff..9325d79e0 100644 --- a/src/commands/cluster/tasks.ts +++ b/src/commands/cluster/tasks.ts @@ -24,8 +24,15 @@ import * as constants from '../../core/constants.js'; import path from 'path'; import chalk from 'chalk'; import {ListrLease} from '../../core/lease/listr_lease.js'; +import {ErrorMessages} from '../../core/error_messages.js'; +import {SoloError} from '../../core/errors.js'; +import {type Context} from '@kubernetes/client-node'; +import {RemoteConfigManager} from '../../core/config/remote/remote_config_manager.js'; +import {type RemoteConfigDataWrapper} from '../../core/config/remote/remote_config_data_wrapper.js'; import {type K8} from '../../core/k8.js'; import {ListrEnquirerPromptAdapter} from '@listr2/prompt-adapter-enquirer'; +import {type LocalConfig} from '../../core/config/local_config.js'; +import {type Cluster} from '@kubernetes/client-node/dist/config_types.js'; export class ClusterCommandTasks { private readonly parent: BaseCommand; @@ -37,6 +44,80 @@ export class ClusterCommandTasks { this.parent = parent; } + testConnectionToCluster(cluster: string, localConfig: LocalConfig, parentTask: ListrTaskWrapper) { + const self = this; + return { + title: `Test connection to cluster: ${chalk.cyan(cluster)}`, + task: async (_, subTask: ListrTaskWrapper) => { + let context = localConfig.clusterContextMapping[cluster]; + if (!context) { + const isQuiet = self.parent.getConfigManager().getFlag(flags.quiet); + if (isQuiet) { + context = self.parent.getK8().getKubeConfig().currentContext; + } else { + context = await self.promptForContext(parentTask, cluster); + } + + localConfig.clusterContextMapping[cluster] = context; + } + if (!(await self.parent.getK8().testClusterConnection(context, cluster))) { + subTask.title = `${subTask.title} - ${chalk.red('Cluster connection failed')}`; + throw new SoloError(`${ErrorMessages.INVALID_CONTEXT_FOR_CLUSTER_DETAILED(context, cluster)}`); + } + }, + }; + } + + validateRemoteConfigForCluster( + cluster: string, + currentCluster: Cluster, + localConfig: LocalConfig, + currentRemoteConfig: RemoteConfigDataWrapper, + ) { + const self = this; + return { + title: `Pull and validate remote configuration for cluster: ${chalk.cyan(cluster)}`, + task: async (_, subTask: ListrTaskWrapper) => { + const context = localConfig.clusterContextMapping[cluster]; + self.parent.getK8().setCurrentContext(context); + const remoteConfigFromOtherCluster = await self.parent.getRemoteConfigManager().get(); + if (!RemoteConfigManager.compare(currentRemoteConfig, remoteConfigFromOtherCluster)) { + throw new SoloError(ErrorMessages.REMOTE_CONFIGS_DO_NOT_MATCH(currentCluster.name, cluster)); + } + }, + }; + } + + readClustersFromRemoteConfig(argv) { + const self = this; + return { + title: 'Read clusters from remote config', + task: async (ctx, task) => { + const localConfig = this.parent.getLocalConfig(); + const currentCluster = this.parent.getK8().getKubeConfig().getCurrentCluster(); + const currentRemoteConfig: RemoteConfigDataWrapper = await this.parent.getRemoteConfigManager().get(); + const subTasks = []; + const remoteConfigClusters = Object.keys(currentRemoteConfig.clusters); + const otherRemoteConfigClusters: string[] = remoteConfigClusters.filter(c => c !== currentCluster.name); + + // Validate connections for the other clusters + for (const cluster of otherRemoteConfigClusters) { + subTasks.push(self.testConnectionToCluster(cluster, localConfig, task)); + } + + // Pull and validate RemoteConfigs from the other clusters + for (const cluster of otherRemoteConfigClusters) { + subTasks.push(self.validateRemoteConfigForCluster(cluster, currentCluster, localConfig, currentRemoteConfig)); + } + + return task.newListr(subTasks, { + concurrent: false, + rendererOptions: {collapseSubtasks: false}, + }); + }, + }; + } + updateLocalConfig(argv) { return new Task('Update local configuration', async (ctx: any, task: ListrTaskWrapper) => { this.parent.logger.info('Compare local and remote configuration...'); @@ -48,13 +129,24 @@ export class ClusterCommandTasks { 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); + + const namespace = remoteConfig.metadata.name; + localConfig.currentDeploymentName = remoteConfig.metadata.name; + + if (localConfig.deployments[namespace]) { + 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; + } else { + const clusters = Object.keys(remoteConfig.clusters); + localDeployments[namespace] = {clusters}; + ctx.config.clusters = clusters; } - ctx.config.clusters = remoteClusterList; - localDeployments[localConfig.currentDeploymentName].clusters = ctx.config.clusters; + localConfig.setDeployments(localDeployments); const contexts = splitFlagInput(configManager.getFlag(flags.context)); @@ -170,6 +262,7 @@ export class ClusterCommandTasks { const contexts = splitFlagInput(configManager.getFlag(flags.context)); const localConfig = this.parent.getLocalConfig(); let selectedContext; + let selectedCluster; // If one or more contexts are provided use the first one if (contexts.length) { @@ -179,6 +272,7 @@ export class ClusterCommandTasks { // 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) { + selectedCluster = clusters[0]; selectedContext = await this.selectContextForFirstCluster(task, clusters, localConfig, isQuiet); } @@ -188,6 +282,7 @@ export class ClusterCommandTasks { const deployment = localConfig.deployments[deploymentName]; if (deployment && deployment.clusters.length) { + selectedCluster = deployment.clusters[0]; selectedContext = await this.selectContextForFirstCluster(task, deployment.clusters, localConfig, isQuiet); } @@ -196,7 +291,7 @@ export class ClusterCommandTasks { // 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; + selectedCluster = this.parent.getK8().getKubeConfig().getCurrentCluster().name; localConfig.deployments[deploymentName] = { clusters: [selectedCluster], }; @@ -208,7 +303,8 @@ export class ClusterCommandTasks { // Prompt user for clusters and contexts else { - clusters = splitFlagInput(await flags.clusterName.prompt(task, clusters)); + const promptedClusters = await flags.clusterName.prompt(task, ''); + clusters = splitFlagInput(promptedClusters); for (const cluster of clusters) { if (!localConfig.clusterContextMapping[cluster]) { @@ -216,12 +312,17 @@ export class ClusterCommandTasks { } } + selectedCluster = clusters[0]; selectedContext = localConfig.clusterContextMapping[clusters[0]]; } } } - this.parent.getK8().getKubeConfig().setCurrentContext(selectedContext); + const connectionValid = await this.parent.getK8().testClusterConnection(selectedContext, selectedCluster); + if (!connectionValid) { + throw new SoloError(ErrorMessages.INVALID_CONTEXT_FOR_CLUSTER(selectedContext)); + } + this.parent.getK8().setCurrentContext(selectedContext); }); } @@ -390,10 +491,4 @@ export class ClusterCommandTasks { ctx => !ctx.isChartInstalled, ); } - - setupHomeDirectory() { - return new Task('Setup home directory', async () => { - this.parent.setupHomeDirectory(); - }); - } } diff --git a/src/commands/deployment.ts b/src/commands/deployment.ts index 6187aadc0..cb2ed87b5 100644 --- a/src/commands/deployment.ts +++ b/src/commands/deployment.ts @@ -72,31 +72,25 @@ export class DeploymentCommand extends BaseCommand { } 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.setupHomeDirectoryTask(), this.localConfig.promptLocalConfigTask(self.k8), { title: 'Validate cluster connections', task: async (ctx, task): Promise> => { const subTasks = []; - for (const cluster of Object.keys(ctx.config.contextCluster)) { + for (const context of Object.keys(ctx.config.contextCluster)) { + const cluster = ctx.config.contextCluster[context]; subTasks.push({ title: `Testing connection to cluster: ${chalk.cyan(cluster)}`, task: async (_: Context, task: ListrTaskWrapper) => { - if (!(await self.k8.testClusterConnection(cluster))) { + if (!(await self.k8.testClusterConnection(context, cluster))) { task.title = `${task.title} - ${chalk.red('Cluster connection failed')}`; - throw new SoloError(`Cluster connection failed for: ${cluster}`); } }, @@ -104,12 +98,26 @@ export class DeploymentCommand extends BaseCommand { } return task.newListr(subTasks, { - concurrent: true, + concurrent: false, + rendererOptions: {collapseSubtasks: false}, + }); + }, + }, + { + title: 'Create remoteConfig in clusters', + task: async (ctx, task) => { + const subTasks = []; + for (const context of Object.keys(ctx.config.contextCluster)) { + const cluster = ctx.config.contextCluster[context]; + subTasks.push(RemoteConfigTasks.createRemoteConfig.bind(this)(cluster, context, ctx.config.namespace)); + } + + return task.newListr(subTasks, { + concurrent: false, rendererOptions: {collapseSubtasks: false}, }); }, }, - RemoteConfigTasks.createRemoteConfig.bind(this)(), ], { concurrent: false, diff --git a/src/commands/flags.ts b/src/commands/flags.ts index 21fd258aa..7eba8887a 100644 --- a/src/commands/flags.ts +++ b/src/commands/flags.ts @@ -127,7 +127,6 @@ export class Flags { name: 'cluster-name', definition: { describe: 'Cluster name', - defaultValue: 'solo-cluster-setup', alias: 'c', type: 'string', }, diff --git a/src/core/config/remote/remote_config_manager.ts b/src/core/config/remote/remote_config_manager.ts index 4efbb2661..d2cd892ee 100644 --- a/src/core/config/remote/remote_config_manager.ts +++ b/src/core/config/remote/remote_config_manager.ts @@ -24,7 +24,7 @@ import * as yaml from 'yaml'; import {ComponentsDataWrapper} from './components_data_wrapper.js'; import {RemoteConfigValidator} from './remote_config_validator.js'; import {K8} from '../../k8.js'; -import type {Cluster, Namespace} from './types.js'; +import type {Cluster, Context, Namespace} from './types.js'; import {SoloLogger} from '../../logging.js'; import {ConfigManager} from '../../config_manager.js'; import {LocalConfig} from '../local_config.js'; @@ -35,6 +35,7 @@ import type * as k8s from '@kubernetes/client-node'; import {StatusCodes} from 'http-status-codes'; import {inject, injectable} from 'tsyringe-neo'; import {patchInject} from '../../container_helper.js'; +import {ErrorMessages} from '../../error_messages.js'; interface ListrContext { config: {contextCluster: ContextClusterStructure}; @@ -151,6 +152,35 @@ export class RemoteConfigManager { return true; } + /** + * Loads the remote configuration, performs a validation and returns it + * @returns RemoteConfigDataWrapper + */ + public async get(): Promise { + await this.load(); + try { + await RemoteConfigValidator.validateComponents(this.remoteConfig.components, this.k8); + } catch (e) { + throw new SoloError(ErrorMessages.REMOTE_CONFIG_IS_INVALID(this.k8.getKubeConfig().getCurrentCluster().name)); + } + return this.remoteConfig; + } + + public static compare(remoteConfig1: RemoteConfigDataWrapper, remoteConfig2: RemoteConfigDataWrapper): boolean { + // Compare clusters + const clusters1 = Object.keys(remoteConfig1.clusters); + const clusters2 = Object.keys(remoteConfig2.clusters); + if (clusters1.length !== clusters2.length) return false; + + for (const i in clusters1) { + if (clusters1[i] !== clusters2[i]) { + return false; + } + } + + return true; + } + /* ---------- Listr Task Builders ---------- */ /** @@ -197,20 +227,26 @@ export class RemoteConfigManager { * * @returns a Listr task which creates the remote configuration. */ - public buildCreateTask(): SoloListrTask { + public buildCreateTask(cluster: Cluster, context: Context, namespace: Namespace): SoloListrTask { const self = this; return { - title: 'Create remote config', + title: `Create remote config in cluster: ${cluster}`, task: async (_, task): Promise => { + self.k8.setCurrentContext(context); + + if (!(await self.k8.hasNamespace(namespace))) { + await self.k8.createNamespace(namespace); + } + const localConfigExists = this.localConfig.configFileExists(); if (!localConfigExists) { throw new SoloError("Local config doesn't exist"); } + self.unload(); if (await self.load()) { task.title = `${task.title} - ${chalk.red('Remote config already exists')}}`; - throw new SoloError('Remote config already exists'); } @@ -232,6 +268,10 @@ export class RemoteConfigManager { return !!this.remoteConfig; } + public unload() { + delete this.remoteConfig; + } + /** * Retrieves the ConfigMap containing the remote configuration from the Kubernetes cluster. * diff --git a/src/core/config/remote/remote_config_tasks.ts b/src/core/config/remote/remote_config_tasks.ts index 9690835a9..03c381fb1 100644 --- a/src/core/config/remote/remote_config_tasks.ts +++ b/src/core/config/remote/remote_config_tasks.ts @@ -16,6 +16,7 @@ */ import type {ListrTask} from 'listr2'; import type {BaseCommand} from '../../../commands/base.js'; +import {type Cluster, type Context, type Namespace} from './types.js'; /** * Static class that handles all tasks related to remote config used by other commands. @@ -33,7 +34,12 @@ export class RemoteConfigTasks { } /** Creates remote config. */ - public static createRemoteConfig(this: BaseCommand): ListrTask { - return this.remoteConfigManager.buildCreateTask(); + public static createRemoteConfig( + this: BaseCommand, + cluster: Cluster, + context: Context, + namespace: Namespace, + ): ListrTask { + return this.remoteConfigManager.buildCreateTask(cluster, context, namespace); } } diff --git a/src/core/error_messages.ts b/src/core/error_messages.ts index d1cf3bfd4..00583f0e2 100644 --- a/src/core/error_messages.ts +++ b/src/core/error_messages.ts @@ -22,4 +22,12 @@ export const ErrorMessages = { 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', + INVALID_CONTEXT_FOR_CLUSTER: (context: string, cluster?: string) => + `Context ${context} is not valid for cluster ${cluster || ''}`, + INVALID_CONTEXT_FOR_CLUSTER_DETAILED: (context: string, cluster?: string) => + `Context ${context} is not valid for cluster ${cluster || ''}. Please select a valid context for the cluster or use kubectl to create a new context and try again`, + REMOTE_CONFIGS_DO_NOT_MATCH: (cluster1: string, cluster2: string) => + `The remote configurations in clusters ${cluster1} and ${cluster2} do not match. They need to be synced manually. Please select a valid context for the cluster or use kubectl to create a new context and try again.`, + REMOTE_CONFIG_IS_INVALID: (cluster: string) => + `The remote configuration in cluster ${cluster} is invalid and needs to be fixed manually`, }; diff --git a/src/core/k8.ts b/src/core/k8.ts index ec58f62ba..e025f1275 100644 --- a/src/core/k8.ts +++ b/src/core/k8.ts @@ -1203,13 +1203,13 @@ export class K8 { // --------------------------------------- Utility Methods --------------------------------------- // - public async testClusterConnection(context: string): Promise { + public async testClusterConnection(context: string, cluster: string): Promise { this.kubeConfig.setCurrentContext(context); - return await this.kubeConfig - .makeApiClient(k8s.CoreV1Api) + const tempKubeClient = this.kubeConfig.makeApiClient(k8s.CoreV1Api); + return await tempKubeClient .listNamespace() - .then(() => true) + .then(() => this.getKubeConfig().getCurrentCluster().name === cluster) .catch(() => false); } @@ -1381,7 +1381,7 @@ export class K8 { return resp.response.statusCode === StatusCodes.CREATED; } catch (e) { throw new SoloError( - `failed to create configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, + `failed to replace configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, e, ); } @@ -1394,7 +1394,7 @@ export class K8 { return resp.response.statusCode === StatusCodes.CREATED; } catch (e) { throw new SoloError( - `failed to create configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, + `failed to delete configmap ${name} in namespace ${namespace}: ${e.message}, ${e?.body?.message}`, e, ); } @@ -1728,4 +1728,12 @@ export class K8 { } this.logger.debug(`getNodeState(${pod.metadata.name}): ...end`); } + + setCurrentContext(context: string) { + this.kubeConfig.setCurrentContext(context); + + // Reinitialize clients + this.kubeClient = this.kubeConfig.makeApiClient(k8s.CoreV1Api); + this.coordinationApiClient = this.kubeConfig.makeApiClient(k8s.CoordinationV1Api); + } } diff --git a/src/index.ts b/src/index.ts index 8e68d3e55..896855015 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,9 +106,8 @@ export function main(argv: any) { configManager.reset(); } - // Set default cluster name and namespace from kubernetes context - // these will be overwritten if user has entered the flag values explicitly - configManager.setFlag(flags.clusterName, cluster.name); + const clusterName = configManager.getFlag(flags.clusterName) || cluster.name; + if (context.namespace) { configManager.setFlag(flags.namespace, context.namespace); } @@ -124,7 +123,7 @@ export function main(argv: any) { ); logger.showUser(chalk.cyan('Version\t\t\t:'), chalk.yellow(configManager.getVersion())); logger.showUser(chalk.cyan('Kubernetes Context\t:'), chalk.yellow(context.name)); - logger.showUser(chalk.cyan('Kubernetes Cluster\t:'), chalk.yellow(configManager.getFlag(flags.clusterName))); + logger.showUser(chalk.cyan('Kubernetes Cluster\t:'), chalk.yellow(clusterName)); if (configManager.getFlag(flags.namespace) !== undefined) { logger.showUser(chalk.cyan('Kubernetes Namespace\t:'), chalk.yellow(configManager.getFlag(flags.namespace))); } diff --git a/test/unit/commands/cluster.test.ts b/test/unit/commands/cluster.test.ts index a8a8dffef..2b1777271 100644 --- a/test/unit/commands/cluster.test.ts +++ b/test/unit/commands/cluster.test.ts @@ -57,6 +57,9 @@ import type {Opts} from '../../../src/types/command_types.js'; import type {ListrTaskWrapper} from 'listr2'; import fs from 'fs'; import {stringify} from 'yaml'; +import {ErrorMessages} from '../../../src/core/error_messages.js'; +import {SoloError} from '../../../src/core/errors.js'; +import {RemoteConfigDataWrapper} from '../../../src/core/config/remote/remote_config_data_wrapper.js'; const getBaseCommandOpts = () => ({ logger: sinon.stub(), @@ -142,16 +145,27 @@ describe('ClusterCommand unit tests', () => { let tasks: ClusterCommandTasks; let command: BaseCommand; let loggerStub: sinon.SinonStubbedInstance; + let k8Stub: sinon.SinonStubbedInstance; + let remoteConfigManagerStub: sinon.SinonStubbedInstance; let localConfig: LocalConfig; + const defaultRemoteConfig = { + metadata: { + name: 'deployment', + }, + clusters: {}, + }; const getBaseCommandOpts = ( sandbox: sinon.SinonSandbox, remoteConfig: any = {}, // @ts-ignore stubbedFlags: Record[] = [], + opts: any = { + testClusterConnectionError: false, + }, ) => { const loggerStub = sandbox.createStubInstance(SoloLogger); - const k8Stub = sandbox.createStubInstance(K8); + k8Stub = sandbox.createStubInstance(K8); k8Stub.getContexts.returns([ {cluster: 'cluster-1', user: 'user-1', name: 'context-1', namespace: 'deployment-1'}, {cluster: 'cluster-2', user: 'user-2', name: 'context-2', namespace: 'deployment-2'}, @@ -160,6 +174,13 @@ describe('ClusterCommand unit tests', () => { k8Stub.isMinioInstalled.returns(new Promise(() => true)); k8Stub.isPrometheusInstalled.returns(new Promise(() => true)); k8Stub.isCertManagerInstalled.returns(new Promise(() => true)); + + if (opts.testClusterConnectionError) { + k8Stub.testClusterConnection.resolves(false); + } else { + k8Stub.testClusterConnection.resolves(true); + } + const kubeConfigStub = sandbox.createStubInstance(KubeConfig); kubeConfigStub.getCurrentContext.returns('context-from-kubeConfig'); kubeConfigStub.getCurrentCluster.returns({ @@ -171,10 +192,11 @@ describe('ClusterCommand unit tests', () => { tlsServerName: 'tls-3', } as Cluster); - const remoteConfigManagerStub = sandbox.createStubInstance(RemoteConfigManager); + remoteConfigManagerStub = sandbox.createStubInstance(RemoteConfigManager); remoteConfigManagerStub.modify.callsFake(async callback => { await callback(remoteConfig); }); + remoteConfigManagerStub.get.resolves(remoteConfig); k8Stub.getKubeConfig.returns(kubeConfigStub); @@ -240,11 +262,11 @@ describe('ClusterCommand unit tests', () => { }); it('should update currentDeployment with clusters from remoteConfig', async () => { - const remoteConfig = { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { clusters: { 'cluster-2': 'deployment', }, - }; + }); const opts = getBaseCommandOpts(sandbox, remoteConfig, []); command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath); @@ -258,11 +280,11 @@ describe('ClusterCommand unit tests', () => { }); it('should update clusterContextMapping with provided context', async () => { - const remoteConfig = { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { clusters: { 'cluster-2': 'deployment', }, - }; + }); const opts = getBaseCommandOpts(sandbox, remoteConfig, [[flags.context, 'provided-context']]); command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath); @@ -276,13 +298,13 @@ describe('ClusterCommand unit tests', () => { }); it('should update multiple clusterContextMappings with provided contexts', async () => { - const remoteConfig = { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { clusters: { 'cluster-2': 'deployment', 'cluster-3': 'deployment', 'cluster-4': 'deployment', }, - }; + }); const opts = getBaseCommandOpts(sandbox, remoteConfig, [ [flags.context, 'provided-context-2,provided-context-3,provided-context-4'], ]); @@ -300,12 +322,12 @@ describe('ClusterCommand unit tests', () => { }); it('should update multiple clusterContextMappings with default KubeConfig context if quiet=true', async () => { - const remoteConfig = { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { clusters: { 'cluster-2': 'deployment', 'cluster-3': 'deployment', }, - }; + }); const opts = getBaseCommandOpts(sandbox, remoteConfig, [[flags.quiet, true]]); command = await runUpdateLocalConfigTask(opts); // @ts-ignore localConfig = new LocalConfig(filePath); @@ -320,12 +342,12 @@ describe('ClusterCommand unit tests', () => { }); it('should update multiple clusterContextMappings with prompted context no value was provided', async () => { - const remoteConfig = { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { clusters: { 'cluster-2': 'deployment', 'new-cluster': 'deployment', }, - }; + }); const opts = getBaseCommandOpts(sandbox, remoteConfig, []); command = await runUpdateLocalConfigTask(opts); // @ts-ignore @@ -381,21 +403,21 @@ describe('ClusterCommand unit tests', () => { ]); command = await runSelectContextTask(opts); // @ts-ignore - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('provided-context-1'); + expect(command.getK8().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(command.getK8().setCurrentContext).to.have.been.calledWith('context-2'); }); it('should prompt for context if selected cluster is not found in local config mapping', async () => { const opts = getBaseCommandOpts(sandbox, {}, [[flags.clusterName, 'cluster-3']]); command = await runSelectContextTask(opts); // @ts-ignore - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-3'); + expect(command.getK8().setCurrentContext).to.have.been.calledWith('context-3'); }); it('should use default kubeConfig context if selected cluster is not found in local config mapping and quiet=true', async () => { @@ -405,21 +427,21 @@ describe('ClusterCommand unit tests', () => { ]); command = await runSelectContextTask(opts); // @ts-ignore - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); + expect(command.getK8().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(command.getK8().setCurrentContext).to.have.been.calledWith('context-2'); }); 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'); + expect(command.getK8().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 () => { @@ -429,14 +451,14 @@ describe('ClusterCommand unit tests', () => { ]); command = await runSelectContextTask(opts); // @ts-ignore - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); + expect(command.getK8().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'); + expect(command.getK8().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 () => { @@ -446,7 +468,164 @@ describe('ClusterCommand unit tests', () => { ]); command = await runSelectContextTask(opts); // @ts-ignore - expect(command.getK8().getKubeConfig().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); + expect(command.getK8().setCurrentContext).to.have.been.calledWith('context-from-kubeConfig'); + }); + + it('throws error when context is invalid', async () => { + const opts = getBaseCommandOpts(sandbox, {}, [[flags.context, 'invalid-context']], { + testClusterConnectionError: true, + }); + + try { + await runSelectContextTask(opts); + expect(true).to.be.false; + } catch (e) { + expect(e.message).to.eq(ErrorMessages.INVALID_CONTEXT_FOR_CLUSTER('invalid-context')); + } + }); + }); + + describe('readClustersFromRemoteConfig', () => { + let taskStub; + + async function runReadClustersFromRemoteConfigTask(opts) { + command = new ClusterCommand(opts); + tasks = new ClusterCommandTasks(command, k8Stub); + const taskObj = tasks.readClustersFromRemoteConfig({}); + taskStub = sandbox.stub() as unknown as ListrTaskWrapper; + taskStub.newListr = sandbox.stub(); + await taskObj.task({config: {}}, taskStub); + return command; + } + + async function runSubTasks(subTasks) { + const stubs = []; + for (const subTask of subTasks) { + const subTaskStub = sandbox.stub() as unknown as ListrTaskWrapper; + subTaskStub.newListr = sandbox.stub(); + await subTask.task({config: {}}, subTaskStub); + stubs.push(subTaskStub); + } + + return stubs; + } + + afterEach(async () => { + await fs.promises.unlink(filePath); + sandbox.restore(); + }); + + beforeEach(async () => { + contextPromptStub = sandbox.stub(flags.context, 'prompt').callsFake(() => { + return new Promise(resolve => { + resolve('prompted-context'); + }); + }); + + loggerStub = sandbox.createStubInstance(SoloLogger); + await fs.promises.writeFile(filePath, stringify(testLocalConfigData)); + }); + + it('should load RemoteConfig when there is only 1 cluster', async () => { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { + clusters: { + 'cluster-3': 'deployment', + }, + }); + const opts = getBaseCommandOpts(sandbox, remoteConfig, []); + command = await runReadClustersFromRemoteConfigTask(opts); + + expect(taskStub.newListr).calledWith([]); + }); + + it('should test other clusters and pull their respective RemoteConfigs', async () => { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { + clusters: { + 'cluster-2': 'deployment', + 'cluster-3': 'deployment', + }, + }); + const opts = getBaseCommandOpts(sandbox, remoteConfig, []); + command = await runReadClustersFromRemoteConfigTask(opts); + expect(taskStub.newListr).calledOnce; + const subTasks = taskStub.newListr.firstCall.firstArg; + expect(subTasks.length).to.eq(2); + await runSubTasks(subTasks); + expect(contextPromptStub).not.called; + expect(command.getK8().setCurrentContext).to.have.been.calledWith('context-2'); + expect(command.getK8().testClusterConnection).calledOnce; + expect(command.getK8().testClusterConnection).calledWith('context-2', 'cluster-2'); + }); + + it('should prompt for context when reading unknown cluster', async () => { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { + clusters: { + 'cluster-3': 'deployment', + 'cluster-4': 'deployment', + }, + }); + const opts = getBaseCommandOpts(sandbox, remoteConfig, []); + command = await runReadClustersFromRemoteConfigTask(opts); + expect(taskStub.newListr).calledOnce; + const subTasks = taskStub.newListr.firstCall.firstArg; + expect(subTasks.length).to.eq(2); + await runSubTasks(subTasks); + expect(contextPromptStub).calledOnce; + expect(command.getK8().setCurrentContext).to.have.been.calledWith('prompted-context'); + expect(command.getK8().testClusterConnection).calledOnce; + expect(command.getK8().testClusterConnection).calledWith('prompted-context', 'cluster-4'); + }); + + it('should throw error for invalid prompted context', async () => { + const remoteConfig = Object.assign({}, defaultRemoteConfig, { + clusters: { + 'cluster-3': 'deployment', + 'cluster-4': 'deployment', + }, + }); + const opts = getBaseCommandOpts(sandbox, remoteConfig, [], {testClusterConnectionError: true}); + command = await runReadClustersFromRemoteConfigTask(opts); + expect(taskStub.newListr).calledOnce; + const subTasks = taskStub.newListr.firstCall.firstArg; + expect(subTasks.length).to.eq(2); + try { + await runSubTasks(subTasks); + expect(true).to.be.false; + } catch (e) { + expect(e.message).to.eq(ErrorMessages.INVALID_CONTEXT_FOR_CLUSTER_DETAILED('prompted-context', 'cluster-4')); + expect(contextPromptStub).calledOnce; + expect(command.getK8().testClusterConnection).calledOnce; + expect(command.getK8().testClusterConnection).calledWith('prompted-context', 'cluster-4'); + } + }); + + it('should throw error when remoteConfigs do not match', async () => { + const remoteConfig: any = Object.assign({}, defaultRemoteConfig, { + clusters: { + 'cluster-3': 'deployment', + 'cluster-4': 'deployment', + }, + }); + const mismatchedRemoteConfig: any = Object.assign({}, defaultRemoteConfig, { + clusters: {'cluster-3': 'deployment'}, + }); + const opts = getBaseCommandOpts(sandbox, remoteConfig, []); + + remoteConfigManagerStub.get.onCall(0).resolves(remoteConfig); + remoteConfigManagerStub.get.onCall(1).resolves(mismatchedRemoteConfig); + command = await runReadClustersFromRemoteConfigTask(opts); + expect(taskStub.newListr).calledOnce; + const subTasks = taskStub.newListr.firstCall.firstArg; + expect(subTasks.length).to.eq(2); + try { + await runSubTasks(subTasks); + expect(true).to.be.false; + } catch (e) { + expect(e.message).to.eq(ErrorMessages.REMOTE_CONFIGS_DO_NOT_MATCH('cluster-3', 'cluster-4')); + expect(contextPromptStub).calledOnce; + expect(command.getK8().testClusterConnection).calledOnce; + expect(command.getK8().testClusterConnection).calledWith('prompted-context', 'cluster-4'); + } }); }); });