From cad5d26ea785b1581965419a811889bbbb772ac7 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Sat, 8 Mar 2025 15:42:52 +0100 Subject: [PATCH] Allow people to use KUBECONFIG env var and multiple config files #4816 Signed-off-by: Victor Rubezhny --- images/context/cluster-node-gray.png | Bin 0 -> 11545 bytes package.json | 14 +- src/explorer.ts | 61 +++- src/extension.ts | 13 +- src/k8s/console.ts | 7 +- src/oc/ocWrapper.ts | 216 +++++++++--- src/openshift/cluster.ts | 244 ++++++++----- src/openshift/project.ts | 6 +- src/util/kubeUtils.ts | 493 +++++++++++++++++++++------ src/util/utils.ts | 8 +- test/ui/suite/kubernetesContext.ts | 34 +- test/unit/k8s/console.test.ts | 5 +- test/unit/openshift/cluster.test.ts | 8 +- test/unit/util/kubeUtils.test.ts | 17 +- 14 files changed, 850 insertions(+), 276 deletions(-) create mode 100644 images/context/cluster-node-gray.png diff --git a/images/context/cluster-node-gray.png b/images/context/cluster-node-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..2914449f0a3d1171d984563df1526e1463c38dd4 GIT binary patch literal 11545 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 3e5ada687..aed34de44 100644 --- a/package.json +++ b/package.json @@ -146,9 +146,9 @@ "got": "^11.8.6", "hasha": "^5.2.2", "istanbul": "^0.4.5", + "js-yaml": "^4.1.0", "json-schema": "^0.4.0", "json-to-ast": "^2.1.0", - "js-yaml": "^4.1.0", "leasot": "^14.4.0", "lodash": "^4.17.21", "mkdirp": "^3.0.1", @@ -188,8 +188,8 @@ "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", "xterm-addon-serialize": "^0.11.0", - "xterm-addon-webgl": "^0.16.0", "xterm-addon-web-links": "^0.9.0", + "xterm-addon-webgl": "^0.16.0", "xterm-headless": "^5.3.0" }, "overrides": { @@ -222,6 +222,7 @@ "onCommand:openshift.explorer.login.clipboard", "onCommand:openshift.explorer.logout", "onCommand:openshift.explorer.refresh", + "onCommand:openshift.explorer.describe.kubeconfig", "onCommand:openshift.componentTypesView.refresh", "onCommand:openshift.project.create", "onCommand:openshift.project.set", @@ -281,7 +282,7 @@ "icons": { "current-context": { "description": "context", - "default": "selection" + "default": "layers-active" }, "project-node": { "description": "project node", @@ -391,6 +392,11 @@ "light": "images/title/light/icon-issue.svg" } }, + { + "command": "openshift.explorer.describe.kubeconfig", + "title": "Print effective Kube config", + "category": "OpenShift" + }, { "command": "openshift.show.feedback", "title": "Share your feedback", @@ -2294,4 +2300,4 @@ "publisherId": "eed56242-9699-4317-8bc7-e9f4b9bdd3ff", "isPreReleaseVersion": false } -} \ No newline at end of file +} diff --git a/src/explorer.ts b/src/explorer.ts index 39d15ad09..3e0802c4d 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -6,18 +6,20 @@ import { Context, KubernetesObject } from '@kubernetes/client-node'; import * as fs from 'fs'; import * as path from 'path'; +import * as tmp from 'tmp'; import { + commands, Disposable, Event, EventEmitter, + extensions, + TextDocumentShowOptions, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, Uri, - commands, - extensions, version, window, workspace @@ -31,7 +33,7 @@ import { Oc } from './oc/ocWrapper'; import { Component } from './openshift/component'; import { getServiceKindStubs, getServices } from './openshift/serviceHelpers'; import { PortForward } from './port-forward'; -import { KubeConfigUtils, getKubeConfigFiles, getNamespaceKind, isOpenShiftCluster } from './util/kubeUtils'; +import { getKubeConfigFiles, getNamespaceKind, isOpenShiftCluster, KubeConfigInfo } from './util/kubeUtils'; import { LoginUtil } from './util/loginUtil'; import { Platform } from './util/platform'; import { Progress } from './util/progress'; @@ -106,7 +108,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos private kubeConfigWatchers: FileContentChangeNotifier[]; private kubeContext: Context; - private kubeConfig: KubeConfigUtils; + private kubeConfigInfo: KubeConfigInfo; private executionContext: ExecutionContext = new ExecutionContext(); @@ -120,8 +122,8 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos private constructor() { try { - this.kubeConfig = new KubeConfigUtils(); - this.kubeContext = this.kubeConfig.getContextObject(this.kubeConfig.currentContext); + this.kubeConfigInfo = new KubeConfigInfo(); + this.kubeContext = this.kubeConfigInfo.getEffectiveKubeConfig().getContextObject(this.kubeConfigInfo.getEffectiveKubeConfig().currentContext); } catch { // ignore config loading error and let odo report it on first call } @@ -137,17 +139,18 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos } for (const fsw of this.kubeConfigWatchers) { fsw.emitter?.on('file-changed', () => { - const ku2 = new KubeConfigUtils(); - const newCtx = ku2.getContextObject(ku2.currentContext); + const kci2 = new KubeConfigInfo(); + const kc2 = kci2.getEffectiveKubeConfig(); + const newCtx = kc2.getContextObject(kc2.currentContext); if (Boolean(this.kubeContext) !== Boolean(newCtx) - || (this.kubeContext.cluster !== newCtx.cluster - || this.kubeContext.user !== newCtx.user - || this.kubeContext.namespace !== newCtx.namespace)) { + || (this.kubeContext?.cluster !== newCtx?.cluster + || this.kubeContext?.user !== newCtx?.user + || this.kubeContext?.namespace !== newCtx?.namespace)) { this.refresh(); this.onDidChangeContextEmitter.fire(newCtx?.name); // newCtx can be 'null' } this.kubeContext = newCtx; - this.kubeConfig = ku2; + this.kubeConfigInfo = kci2; }); } this.treeView = window.createTreeView('openshiftProjectExplorer', { @@ -202,7 +205,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos void commands.executeCommand('setContext', 'isLoggedIn', true); return { contextValue: 'openshift.k8sContext', - label: this.kubeConfig.getCluster(element.cluster)?.server, + label: this.kubeConfigInfo.getEffectiveKubeConfig().getCluster(element.cluster)?.server, collapsibleState: TreeItemCollapsibleState.Collapsed, iconPath: imagePath('context/cluster-node.png') }; @@ -466,7 +469,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos } catch { // ignore because ether server is not accessible or user is logged out } - OpenShiftExplorer.getInstance().onDidChangeContextEmitter.fire(new KubeConfigUtils().currentContext); + OpenShiftExplorer.getInstance().onDidChangeContextEmitter.fire(this.kubeConfigInfo.getEffectiveKubeConfig().currentContext); } else if ('name' in element) { // we are dealing with context here // user is logged into cluster from current context // and project should be shown as child node of current context @@ -494,7 +497,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos }, } as KubernetesObject] } else { - const projectName = this.kubeConfig.extractProjectNameFromCurrentContext() || 'default'; + const projectName = this.kubeConfigInfo.extractProjectNameFromCurrentContext() || 'default'; result = [await createOrSetProjectItem(projectName, this.executionContext)]; } @@ -544,7 +547,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos try { return this.getPods(element); } catch { - return [ couldNotGetItem(element.kind, this.kubeConfig.getCluster(this.kubeContext.cluster)?.server) ]; + return [ couldNotGetItem(element.kind, this.kubeConfigInfo.getEffectiveKubeConfig().getCluster(this.kubeContext.cluster)?.server) ]; } } else if ('kind' in element && element.kind === 'project') { const deployments = { @@ -651,7 +654,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos try { collections = await Oc.Instance.getKubernetesObjects(element.kind, undefined, undefined, this.executionContext); } catch { - collections = [ couldNotGetItem(element.kind, this.kubeConfig.getCluster(this.kubeContext.cluster)?.server) ]; + collections = [ couldNotGetItem(element.kind, this.kubeConfigInfo.getEffectiveKubeConfig().getCluster(this.kubeContext.cluster)?.server) ]; } break; } @@ -872,4 +875,28 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos ].join('\n'); return `${packageJSON.bugs}/new?labels=kind/bug&title=&body=**Environment**\n${body}\n**Description**`; } + + @vsCommand('openshift.explorer.describe.kubeconfig', true) + static async describeEffectiveConfig(): Promise { + const k8sConfig: string = new KubeConfigInfo().dumpEffectiveKubeConfig(); + const tempK8sConfigFile = await new Promise((resolve, reject) => { + tmp.file({ prefix: 'effective.config' ,postfix: '.yaml' }, (err, name) => { + if (err) { + reject(err); + } + resolve(name); + }); + }); + fs.writeFileSync(tempK8sConfigFile, k8sConfig, 'utf-8') + fs.chmodSync(tempK8sConfigFile, 0o400); + const fileUri = Uri.parse(tempK8sConfigFile); + window.showTextDocument(fileUri, { preview: true, readOnly: true } as TextDocumentShowOptions); + const onCloseSubscription = workspace.onDidCloseTextDocument((closedDoc) => { + if (closedDoc.uri.toString() === fileUri.toString()) { + fs.chmodSync(tempK8sConfigFile, 0o600); + fs.unlinkSync(tempK8sConfigFile); + onCloseSubscription.dispose(); + } + }); + } } diff --git a/src/extension.ts b/src/extension.ts index 92c4cfdd8..540ee6365 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -40,7 +40,7 @@ import { ServerlessFunctionView } from './serverlessFunction/view'; import { startTelemetry } from './telemetry'; import { ToolsConfig } from './tools'; import { TokenStore } from './util/credentialManager'; -import { getNamespaceKind, KubeConfigUtils, setKubeConfig } from './util/kubeUtils'; +import { getNamespaceKind, KubeConfigInfo } from './util/kubeUtils'; import { setupWorkspaceDevfileContext } from './util/workspace'; import { registerCommands } from './vscommand'; import ClusterViewLoader from './webview/cluster/clusterViewLoader'; @@ -87,9 +87,6 @@ export async function activate(extensionContext: ExtensionContext): Promise ctx.name === k8sConfig.currentContext).namespace; + const k8sConfigInfo = new KubeConfigInfo(); + const k8sConfig = k8sConfigInfo.getEffectiveKubeConfig(); + const project = k8sConfig.contexts?.find((ctx) => ctx.name === k8sConfig.currentContext).namespace; return project; } diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index 4f7d68dbd..b0155f65e 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ +import { Cluster, Context, User } from '@kubernetes/client-node'; import { KubernetesObject } from '@kubernetes/client-node/dist/types'; import * as fs from 'fs/promises'; import * as tmp from 'tmp'; @@ -10,7 +11,7 @@ import validator from 'validator'; import { CommandOption, CommandText } from '../base/command'; import { CliChannel, ExecutionContext } from '../cli'; import { CliExitData } from '../util/childProcessUtil'; -import { isOpenShiftCluster, KubeConfigUtils } from '../util/kubeUtils'; +import { isOpenShiftCluster, KubeConfigInfo, loadKubeConfig, serializeKubeConfig } from '../util/kubeUtils'; import { Project } from './project'; import { ClusterType, KubernetesConsole } from './types'; @@ -318,26 +319,18 @@ export class Oc { * @param clusterURL the URL of the cluster to log in to * @param username the username to use when logging in * @param password the password to use when logging in + * @param context the context to be altered, if specified * @param abortController if provided, allows cancelling the operation */ - public async loginWithUsernamePassword( - clusterURL: string, - username: string, - password: string, - abortController?: AbortController - ): Promise { - const options = abortController ? { signal: abortController.signal } : undefined; - const result = await CliChannel.getInstance().executeTool( - new CommandText('oc', `login ${clusterURL}`, [ - new CommandOption('-u', username, true, true), - new CommandOption('-p', password, true, true), - new CommandOption('--insecure-skip-tls-verify'), - ]), - options - ); - if (result.stderr) { - throw new Error(result.stderr); - } + public async loginWithUsernamePassword(clusterURL: string, username: string, password: string, + context?: string, abortController?: AbortController): Promise { + const args: CommandOption[] = [ + new CommandOption('-u', username, true, true), + new CommandOption('-p', password, true, true), + new CommandOption('--insecure-skip-tls-verify') + ]; + + await this.wrapLogin(clusterURL, args, context, abortController); } /** @@ -345,31 +338,170 @@ export class Oc { * * @param clusterURL the URL of the cluster to log in to * @param token the token to use to log in to the cluster + * @param context the context to be altered, if specified * @param abortController if provided, allows cancelling the operation */ - public async loginWithToken(clusterURL: string, token: string, - abortController?: AbortController): Promise { - const options = abortController ? { signal: abortController.signal } : undefined; - const result = await CliChannel.getInstance().executeTool( - new CommandText('oc', `login ${clusterURL}`, [ - new CommandOption('--token', token.trim()), - new CommandOption('--insecure-skip-tls-verify'), - ]), - options - ); - if (result.stderr) { - throw new Error(result.stderr); + public async loginWithToken(clusterURL: string, token: string, context?: string, abortController?: AbortController): Promise { + const args: CommandOption[] = [ + new CommandOption('--token', token.trim()), + new CommandOption('--insecure-skip-tls-verify') + ]; + + await this.wrapLogin(clusterURL, args, context, abortController); + } + + /** + * Executes 'oc login' using the given Cluster URL and the command options, altering the selected + * Kube config, if provided, or creating a new one (created by 'oc') + * + * @param clusterURL the URL of the cluster to log in to + * @param commandOptions A 'CommandOption` array for `oc login` command + * @param selectedContext [optional] A context to alter with the result of login operation + * @param abortController if provided, allows cancelling the operation + * @returns A Kube context name used in or created after successfull login. + */ + private async wrapLogin(clusterURL: string, commandOptions: CommandOption[], selectedContext?: string, abortController?: AbortController): Promise { + return new Promise((resolve, reject) => { + tmp.file(async (err, path, fd, cleanupCallback) => { + if (err) throw err; + try { + const options = abortController ? { signal: abortController.signal } : undefined; + + // Find `--kubeconfig` Command Option and replace its value with the path to temporary config file + const kcOptionIndex = commandOptions.findIndex((o) => o.name === '--kubeconfig'); + if (kcOptionIndex !== -1) { + commandOptions.splice(kcOptionIndex, 1); + } + commandOptions.push(new CommandOption('--kubeconfig', path, true, true)); + + const result = await CliChannel.getInstance().executeTool(new CommandText('oc', `login ${clusterURL}`, commandOptions), options); + if (result.stderr) { + throw new Error(result.stderr); + } + + // Get the context/cluster/user objects from the temporary config created by 'oc login' + const newConfig = loadKubeConfig(path); + let newContext: Context = newConfig?.contexts?.find((c) => c.name === newConfig.currentContext); + let newCluster: Cluster = newContext && newConfig?.clusters?.find((c) => c.name === newContext.cluster); + let newUser: User = newContext && newConfig?.users?.find((u) => u.name === newContext.user); + let newContextPath, newClusterPath, newUserPath; + + // Default path for the Kube config changes + newContextPath = newClusterPath = newUserPath = KubeConfigInfo.getMainContextConfigPath(); + + if (selectedContext) { + // Get the context/cluster/user object mappings for the current context from the Kube config + const k8cConfigInfo = new KubeConfigInfo(); + const [ selectedCtxPath, selectedCtx ] = [...k8cConfigInfo.getContextMap()].find(([key, values]) => values?.find((c) => c.name === selectedContext)) + || [undefined, undefined]; + if (selectedCtx && selectedCtx[0]) { + const [ selectedClusterPath, selectedCluster] = [...k8cConfigInfo.getClusterMap()].find(([key, values]) => values?.find((c) => c.name === selectedCtx[0].cluster)) + || [undefined, undefined]; + + if (selectedCluster && selectedCluster[0]) { + newClusterPath = selectedClusterPath; // Change the path to save the cluster + newCluster = { ...newCluster, name: selectedCtx[0].cluster }; // Replace the only cluster name in newContext + } else { + newClusterPath = selectedCtxPath; // If it's a new cluster, save it to the same config as context + } + + const [ selectedUserPath, selectedUser ] = [...k8cConfigInfo.getUserMap()].find(([key, values]) => values?.find((u) => u.name === selectedCtx[0].user)) + || [undefined, undefined]; + if (selectedUser && selectedUser[0]) { + newUserPath = selectedUserPath; // Change the path to save the user + newUser = { ...newUser, name: selectedCtx[0].user }; // Replace the only user name in newContext + } else { + newUserPath = selectedCtxPath; // If it's a new user, save it to the same config as context + } + + // Update context with the new properties, leaving the others as they are (f.i., namespace property) + newContextPath = selectedCtxPath; // Change the path to save the context + newContext = { ...newContext, name: selectedCtx[0].name, cluster: newCluster.name, user: newUser.name}; + } + } + + type ConfigChange = { context: Context, cluster: Cluster, user: User }; + + // Group the Config items by Config paths so we can minimize file operations + const configItemMap: Map = new Map(); + let item: ConfigChange = configItemMap.get(newContextPath) ; // This is an excessive as we're sure the map is empty gere + configItemMap.set(newContextPath, { context: newContext, cluster: item?.cluster, user: item?.user }); + item = configItemMap.get(newClusterPath); + configItemMap.set(newClusterPath, { context: item?.context, cluster: newCluster, user: item?.user }); + item = configItemMap.get(newUserPath); + configItemMap.set(newUserPath, { context: item?.context, cluster: item?.cluster, user: newUser }); + + // Save the changes + for (const [key, value] of configItemMap.entries()) { + await this.mergeOrAddConfigData(key, value?.context, value?.cluster, value?.user); + } + + // Fix up for a newly created context: if no selectedContext was provided, + // a new context might be created, so we have to switch current context to point + // to that new context + if (newContext.name !== selectedContext) { + await this.setContext(newContext.name); + } + + resolve(selectedContext); + } catch (_err) { + reject(_err); + } finally { + cleanupCallback(); + } + }); + }); + } + + private async mergeOrAddConfigData(configPath: string, context: Context, cluster: Cluster, user: User): Promise { + const kubeConfig =loadKubeConfig(configPath); + + // Merge or add Context + const modifiedContexts: Context[] = kubeConfig.contexts; + const contextIndex = modifiedContexts.findIndex((c) => c.name === context.name); + if (contextIndex !== -1) { // Remove existing context + modifiedContexts.splice(contextIndex, 1); + } + modifiedContexts.unshift(context); + kubeConfig.contexts = modifiedContexts; + + // Merge or add Cluster + const modifiedClusters: Cluster[] = kubeConfig.clusters; + const clusterIndex = modifiedClusters.findIndex((c) => c.name === cluster.name); + if (clusterIndex !== -1) { // Remove existing context + modifiedClusters.splice(clusterIndex, 1); } + modifiedClusters.unshift(cluster); + kubeConfig.clusters = modifiedClusters; + + // Merge or add User + const modifiedUsers: User[] = kubeConfig.users; + const userIndex = modifiedUsers.findIndex((c) => c.name === user.name); + if (userIndex !== -1) { // Remove existing context + modifiedUsers.splice(userIndex, 1); + } + modifiedUsers.unshift(user); + kubeConfig.users = modifiedUsers; + + await fs.writeFile(configPath, serializeKubeConfig(kubeConfig), 'utf8'); } /** * Switches the current Kubernetes context to the given named context. + * If multiple Kube configs are defined in '$KUBECONFIG' env. variable, + * the first 'current-context' instance will be set to the specified context name, while + * all the other instances will be cleared, to prevent 'random' context switching at + * logging off the current cluster. * * @param contextName the name of the context to switch to */ public async setContext(contextName: string): Promise { + // Clear the current Context in all the configs + await this.unsetContext(); + + // Set Context in the main config (default or the first in '$KUBECONFIG' env. variable) await CliChannel.getInstance().executeTool( - new CommandText('oc', `config use-context ${contextName}`), + new CommandText('oc', `config use-context ${contextName}`) ); } @@ -377,9 +509,16 @@ export class Oc { * Clears (unsets) the current Kubernetes context. */ public async unsetContext(): Promise { - await CliChannel.getInstance().executeTool( - new CommandText('oc', 'config unset current-context'), - ); + const allConfigPaths = KubeConfigInfo.getAllConfigPaths(); + // Clear the current Context in all the configs but the main one + for (let i = 0; i < allConfigPaths.length; i++) { + const path = allConfigPaths[i]; + if (path && path.trim().length > 0) { + await CliChannel.getInstance().executeTool( + new CommandText('oc', 'config unset current-context', + [ new CommandOption('--kubeconfig', path.trim(), true, true) ])); + } + } } /** @@ -594,8 +733,9 @@ export class Oc { * @returns The array of Projects with at least one project marked as an active */ public fixActiveProject(projects: Project[], executionContext?: ExecutionContext): Project[] { - const kcu = new KubeConfigUtils(); - const currentContext = kcu.findContext(kcu.currentContext); + const k8sConfigInfo = new KubeConfigInfo(); + const k8sConfig = k8sConfigInfo.getEffectiveKubeConfig(); + const currentContext = k8sConfigInfo.findContext(k8sConfig.currentContext); let fixedProjects = projects.length ? projects : []; let activeProject = undefined; @@ -612,7 +752,7 @@ export class Oc { // [fixup for Sandbox cluster] Get Kube Configs's curernt username and try finding a project, // which name is partially created from that username - const currentUser = kcu.getCurrentUser(); + const currentUser = k8sConfig.getCurrentUser(); if (currentUser) { const projectPrefix = currentUser.name.substring(0, currentUser.name.indexOf('/')); const matches = projectPrefix.match(/^system:serviceaccount:([a-zA-Z-_.]+-dev):pipeline$/); diff --git a/src/openshift/cluster.ts b/src/openshift/cluster.ts index 730031f08..4ecf210b6 100644 --- a/src/openshift/cluster.ts +++ b/src/openshift/cluster.ts @@ -6,7 +6,7 @@ import { CoreV1Api, KubeConfig, KubernetesObject, V1Secret, V1ServiceAccount } from '@kubernetes/client-node'; import { Cluster as KcuCluster, Context as KcuContext } from '@kubernetes/client-node/dist/config_types'; import * as https from 'https'; -import { Disposable, ExtensionContext, QuickInputButtons, QuickPickItem, QuickPickItemButtonEvent, ThemeIcon, Uri, commands, env, window, workspace } from 'vscode'; +import { Disposable, ExtensionContext, QuickInputButtons, QuickPickItem, QuickPickItemButtonEvent, QuickPickItemKind, ThemeIcon, Uri, commands, env, window, workspace } from 'vscode'; import { CommandText } from '../base/command'; import { CliChannel } from '../cli'; import { OpenShiftExplorer } from '../explorer'; @@ -16,10 +16,11 @@ import * as NameValidator from '../openshift/nameValidator'; import { TokenStore } from '../util/credentialManager'; import { Filters } from '../util/filters'; import { inputValue, quickBtn } from '../util/inputValue'; -import { KubeConfigUtils } from '../util/kubeUtils'; +import { KubeConfigInfo, extractProjectNameFromContextName } from '../util/kubeUtils'; import { LoginUtil } from '../util/loginUtil'; import { Platform } from '../util/platform'; import { Progress } from '../util/progress'; +import { imagePath } from '../util/utils'; import { VsCommandError, vsCommand } from '../vscommand'; import { OpenShiftTerminalManager } from '../webview/openshift-terminal/openShiftTerminal'; import OpenShiftItem, { clusterRequired } from './openshiftItem'; @@ -174,8 +175,7 @@ export class Cluster extends OpenShiftItem implements Disposable { } private static getProjectLabel(ctx: KcuContext): string { - const k8sConfig = new KubeConfigUtils(); - const pn = k8sConfig.extractProjectNameFromContextName(ctx.name) || ''; + const pn = extractProjectNameFromContextName(ctx.name) || ''; const ns = ctx.namespace || pn; let label = ns.length > 0 ? ns : '[default]'; if (ns !== pn && pn.length > 0) label = `${label} (${pn})`; @@ -209,33 +209,72 @@ export class Cluster extends OpenShiftItem implements Disposable { static async switchContextInternal(abortController?: AbortController): Promise { return new Promise((resolve, reject) => { - const k8sConfig = new KubeConfigUtils(); - const contexts = k8sConfig.contexts.filter( - (item) => item.name !== k8sConfig.currentContext, - ); const deleteBtn = new quickBtn(new ThemeIcon('trash'), 'Delete'); const quickPick = window.createQuickPick(); - const contextNames: QuickPickItemExt[] = contexts - .map((ctx) => { - return { - ...ctx, - label: Cluster.getProjectLabel(ctx) - } - }) - .map((ctx) => ({ - name: `${ctx.name}`, - cluster: `${ctx.cluster}`, - user: `${ctx.user}`, - namespace: `${ctx.namespace}`, - label: `${ctx.label}`, - description: `on ${ctx.cluster}`, - detail: `User: ${ctx.user}`, - buttons: [deleteBtn], - })); - quickPick.items = contextNames; + + const k8sConfigInfo = new KubeConfigInfo(); + const k8sConfig = k8sConfigInfo.getEffectiveKubeConfig(); + const contextEntries = [...k8sConfigInfo.getContextMap()]; + const contextItems: any[] = []; // Will contain QuickPickItem properties plus some additional ones + + const currentCtxPaths = contextEntries.filter(([key, value]) => value.find((c) => c.name === k8sConfig.currentContext)).map(([key, value]) => key); + + // Show first the items that come from the config that defines the current context + contextEntries.filter(([key, value]) => currentCtxPaths.length > 0 && currentCtxPaths.find((p) => p === key)).forEach(([key, value]) => { + contextItems.push( {kind: QuickPickItemKind.Separator, label: key}); + // Show the current context first + value.filter((ctx) => ctx.name === k8sConfig.currentContext).forEach((ctx) => { + contextItems.push( { + label: ctx.name, + cluster: `${ctx.cluster}`, + user: `${ctx.user}`, + namespace: `${ctx.namespace}`, + description: `on ${ctx.cluster}`, + detail: `Current Context: Project: ${Cluster.getProjectLabel(ctx)}, User: ${ctx.user}`, + iconPath: new ThemeIcon('layers-active'), + picked: true + }); + }); + // Show the rest contexts from the same config + value.filter((ctx) => ctx.name !== k8sConfig.currentContext).forEach((ctx) => { + contextItems.push( { + label: ctx.name, + cluster: `${ctx.cluster}`, + user: `${ctx.user}`, + namespace: `${ctx.namespace}`, + description: `on ${ctx.cluster}`, + detail: `Project: ${Cluster.getProjectLabel(ctx)}, User: ${ctx.user}`, + buttons: [deleteBtn], + iconPath: new ThemeIcon('layers'), + picked: false + }); + }); + }); + // Show the rest items + contextEntries.filter(([key, value]) => currentCtxPaths.length === 0 || currentCtxPaths?.find((p) => p !== key)).forEach(([key, value]) => { + contextItems.push( {kind: QuickPickItemKind.Separator, label: key}); + value.forEach((ctx) => { + contextItems.push( { + label: ctx.name, + cluster: `${ctx.cluster}`, + user: `${ctx.user}`, + namespace: `${ctx.namespace}`, + description: `on ${ctx.cluster}`, + detail: `Project: ${Cluster.getProjectLabel(ctx)}, User: ${ctx.user}`, + buttons: [deleteBtn], + iconPath: new ThemeIcon('layers'), + picked: false + }); + }); + }); + + quickPick.items = contextItems; + quickPick.matchOnDetail = true; + quickPick.matchOnDescription = true; + const cancelBtn = new quickBtn(new ThemeIcon('close'), 'Cancel'); quickPick.buttons = [QuickInputButtons.Back, cancelBtn]; - if (contextNames.length === 0) { + if (contextItems.length === 0) { void window .showInformationMessage( 'You have no Kubernetes contexts yet, please login to a cluster.', @@ -258,15 +297,23 @@ export class Cluster extends OpenShiftItem implements Disposable { const choice = selection[0] as QuickPickItemExt; hideDisposable.dispose(); quickPick.hide(); - Oc.Instance.setContext(choice.name) + if (choice.label === k8sConfig.currentContext) { + const currentContext = k8sConfigInfo.findContext(k8sConfig.currentContext); + const pr = currentContext ? Cluster.getProjectLabel(currentContext) : 'not defined'; + const cl = currentContext ? currentContext.cluster : 'not defined'; + const msg = `You've already switched to context '${k8sConfig.currentContext}' with project '${pr}' on cluster '${cl}'.`; + void window.showWarningMessage(msg); + // resolve(msg); + } + Oc.Instance.setContext(choice.label) .then(async () => { - const clusterURL = k8sConfig.findClusterURL(choice.cluster); + const clusterURL = k8sConfigInfo.findClusterURL(choice.cluster); if (await LoginUtil.Instance.requireLogin(clusterURL)) { - const status = await Cluster.login(choice.name, true, abortController); + const status = await Cluster.login(choice.label, true, abortController); if (status) { - const newKcu = new KubeConfigUtils(); // Can be updated after login + const newConfigInfo = new KubeConfigInfo(); // Can be updated after login if (Cluster.isSandboxCluster(clusterURL) - && !newKcu.equalsToCurrentContext(choice.name, choice.cluster, choice.namespace, choice.user)) { + && !newConfigInfo.equalsToCurrentContext(choice.name, choice.cluster, choice.namespace, choice.user)) { await window.showWarningMessage( 'The cluster appears to be a OpenShift Dev Sandbox cluster, \ but the required project doesn\'t appear to be existing. \ @@ -276,11 +323,11 @@ export class Cluster extends OpenShiftItem implements Disposable { } } } - const kcu = new KubeConfigUtils(); - const currentContext = kcu.findContext(kcu.currentContext); - const pr = currentContext ? Cluster.getProjectLabel(currentContext) : choice.label; - const cl = currentContext ? currentContext.cluster : choice.description; - resolve(`Cluster context is changed to ${pr} on ${cl}.`); + const kci = new KubeConfigInfo(); + const currentContext = kci.findContext(kci.getEffectiveKubeConfig().currentContext); + const pr = currentContext ? Cluster.getProjectLabel(currentContext) : 'not defined'; + const cl = currentContext ? currentContext.cluster : 'not defined'; + resolve(`Cluster context is changed to '${currentContext.name}' with project '${pr}' on cluster '${cl}'.`); }) .catch(reject); }); @@ -292,9 +339,8 @@ export class Cluster extends OpenShiftItem implements Disposable { await window.showInformationMessage(`Do you want to delete '${event.item.label}' Context from Kubernetes configuration?`, 'Yes', 'No') .then((command: string) => { if (command === 'Yes') { - const context = k8sConfig.getContextObject(event.item.label); - const index = contexts.indexOf(context); - if (index > -1) { + const context = k8sConfigInfo.findContext((event.item as any).name); + if (context) { Oc.Instance.deleteContext(context.name) .then(() => resolve(`Context ${context.name} deleted.`)) .catch(reject); @@ -318,22 +364,67 @@ export class Cluster extends OpenShiftItem implements Disposable { private static async showQuickPick(clusterURl: string, abortController?: AbortController): Promise { return new Promise((resolve, reject) => { - const k8sConfig = new KubeConfigUtils(); const deleteBtn = new quickBtn(new ThemeIcon('trash'), 'Delete'); const createUrl: QuickPickItem = { label: '$(plus) Provide new URL...' }; - const clusterItems = k8sConfig.getServers(); const quickPick = window.createQuickPick(); - const contextNames: QuickPickItem[] = clusterItems.map((ctx) => ({ - ...ctx, - buttons: ctx.description ? [] : [deleteBtn], - })); - quickPick.items = [createUrl, ...contextNames]; + + const k8sConfigInfo = new KubeConfigInfo(); + const k8sConfig = k8sConfigInfo.getEffectiveKubeConfig(); + const clusterEntries = [...k8sConfigInfo.getClusterMap()]; + const currentCluster = k8sConfig.getCurrentCluster(); + const clusterItems: any[] = []; // Will contain QuickPickItem properties plus some additional ones + + const currentClusterPaths = currentCluster ? clusterEntries.filter(([key, value]) => value.find((c) => c.name === currentCluster.name)).map(([key, value]) => key) : []; + + // Show first the items that come from the config that defines the current cluster + clusterEntries.filter(([key, value]) => currentClusterPaths.length > 0 && currentClusterPaths.find((p) => p === key)).forEach(([key, value]) => { + clusterItems.push( {kind: QuickPickItemKind.Separator, label: key}); + // Show the current cluster first + value.filter((cluster) => cluster.name === currentCluster.name).forEach((c) => { + clusterItems.push( { + label: c.name, + server: c.server, + detail: `Current Server: ${c.server}`, + iconPath: imagePath('context/cluster-node.png'), + picked: true + }); + }); + // Show the rest contexts from the same config + value.filter((cluster) => cluster.name !== currentCluster.name).forEach((c) => { + clusterItems.push( { + label: c.name, + server: c.server, + detail: `Server: ${c.server}`, + iconPath: imagePath('context/cluster-node-gray.png'), + picked: false + }); + }); + }); + // Show the rest items + clusterEntries.filter(([key, value]) => currentClusterPaths.length === 0 || currentClusterPaths.find((p) => p !== key)).forEach(([key, value]) => { + clusterItems.push( {kind: QuickPickItemKind.Separator, label: key}); + value.forEach((c) => { + clusterItems.push( { + label: c.name, + server: c.server, + // description: `on ${ctx.cluster}`, + detail: `Server: ${c.server}`, + iconPath: imagePath('context/cluster-node-gray.png'), + picked: false + }); + }); + }); + + quickPick.items = [createUrl, ...clusterItems]; + quickPick.matchOnDetail = true; + quickPick.matchOnDescription = true; + const cancelBtn = new quickBtn(new ThemeIcon('close'), 'Cancel'); quickPick.buttons = [QuickInputButtons.Back, cancelBtn]; let selection: readonly QuickPickItem[] | undefined; const hideDisposable = quickPick.onDidHide(() => resolve(null)); quickPick.onDidAccept(async () => { - const choice = selection[0]; + const choice = selection[0] as any; hideDisposable.dispose(); quickPick.hide(); if (choice.label === createUrl.label) { @@ -344,7 +435,7 @@ export class Cluster extends OpenShiftItem implements Disposable { else if (!newURL) resolve(await Cluster.showQuickPick(clusterURl, abortController)); // Back else resolve(newURL); } else { - resolve(choice.label); + resolve(choice.server); } }); quickPick.onDidChangeSelection((selects) => { @@ -496,18 +587,18 @@ export class Cluster extends OpenShiftItem implements Disposable { * false in case we're already logged in */ static async shouldAskForLoginCredentials(clusterURI: string, contextName?: string): Promise { - const kcu = new KubeConfigUtils(); - const cluster: KcuCluster = kcu.findCluster(clusterURI); + const kci = new KubeConfigInfo(); + const cluster: KcuCluster = kci.findCluster(clusterURI); if (!cluster) return true; - let context: KcuContext = contextName && kcu.findContext(contextName); + let context: KcuContext = contextName && kci.findContext(contextName); if (!context || context.cluster !== context.cluster) { - context = kcu.findContextForCluster(cluster.name); + context = kci.findContextForCluster(cluster.name); } if (!context) return true; // Save `current-context` - const savedContext = kcu.currentContext; + const savedContext = kci.getEffectiveKubeConfig().currentContext; try { await Oc.Instance.setContext(context.name) return await LoginUtil.Instance.requireLogin(cluster.server) @@ -547,7 +638,7 @@ export class Cluster extends OpenShiftItem implements Disposable { private static async checkOngoingOperation(abortController: AbortController): Promise { if (Cluster.ongoingOperationCanceller && Cluster.ongoingOperationCanceller !== abortController) { let response = 'Yes'; - const cluster = new KubeConfigUtils().getCurrentCluster(); + const cluster = new KubeConfigInfo().getEffectiveKubeConfig().getCurrentCluster(); response = await window.showInformationMessage( `You are already trying to login to ${cluster ? cluster.server : ''} cluster. Do you want to login to cancel and try again?`, 'Yes', @@ -588,7 +679,7 @@ export class Cluster extends OpenShiftItem implements Disposable { }).on('success', (s) => { resolve(true); }); - }); + }); } /** @@ -604,9 +695,9 @@ export class Cluster extends OpenShiftItem implements Disposable { let clusterURL: string = undefined; if (context) { // If context is specified, we'll initialize clusterURL from it - const kcu = new KubeConfigUtils(); - const ctx = kcu.findContext(context); - clusterURL = ctx && kcu.findClusterURL(ctx.cluster); + const kci = new KubeConfigInfo(); + const ctx = kci.findContext(context); + clusterURL = ctx && kci.findClusterURL(ctx.cluster); } const localAbortController = abortController || new AbortController(); @@ -641,9 +732,9 @@ export class Cluster extends OpenShiftItem implements Disposable { let clusterURL: string; if (context) { // If context is specified, we'll initialize clusterURL from it - const kcu = new KubeConfigUtils(); - const ctx = kcu.findContext(context); - clusterURL = ctx && kcu.findClusterURL(ctx.cluster); + const kci = new KubeConfigInfo(); + const ctx = kci.findContext(context); + clusterURL = ctx && kci.findClusterURL(ctx.cluster); } enum Step { @@ -702,8 +793,8 @@ export class Cluster extends OpenShiftItem implements Disposable { } // Stop trying because the cluster doesn't appear to be available - void window.showWarningMessage( - 'Unable to contact the cluster. Is it running and accessible?', + void window.showErrorMessage( + `Unable to contact the cluster${ clusterURL && clusterURL.length > 0 ? `: "${clusterURL}"` : ''}. Is it running and accessible?`, ); return null; } @@ -732,8 +823,8 @@ export class Cluster extends OpenShiftItem implements Disposable { case Step.loginUsingCredentials: // Drop down case Step.loginUsingToken: { const successMessage: string = step === Step.loginUsingCredentials - ? await Cluster.credentialsLogin(true, clusterURL, undefined, undefined, abortController) - : await Cluster.tokenLogin(clusterURL, true, undefined, abortController); + ? await Cluster.credentialsLogin(clusterURL, true, context, undefined, undefined, abortController) + : await Cluster.tokenLogin(clusterURL, true, context, undefined, abortController); if (successMessage === null) { // User cancelled the operation return null; @@ -754,7 +845,7 @@ export class Cluster extends OpenShiftItem implements Disposable { private static async requestLoginConfirmation(skipConfirmation = false): Promise { let response = 'Yes'; if (!skipConfirmation && !(await LoginUtil.Instance.requireLogin())) { - const cluster = new KubeConfigUtils().getCurrentCluster(); + const cluster = new KubeConfigInfo().getEffectiveKubeConfig().getCurrentCluster(); response = await window.showInformationMessage( `You are already logged into ${cluster.server} cluster. Do you want to login to a different cluster?`, 'Yes', @@ -795,7 +886,7 @@ export class Cluster extends OpenShiftItem implements Disposable { private static async getUserName(clusterURL: string, addUserLabel: string, abortController?: AbortController): Promise { return new Promise((resolve, reject) => { - const users = new KubeConfigUtils().getClusterUsers(clusterURL); + const users = new KubeConfigInfo().getClusterUsers(clusterURL); const addUser: QuickPickItem = { label: addUserLabel }; const quickPick = window.createQuickPick(); @@ -832,8 +923,8 @@ export class Cluster extends OpenShiftItem implements Disposable { } @vsCommand('openshift.explorer.login.credentialsLogin') - static async credentialsLogin(skipConfirmation = false, userClusterUrl?: string, userName?: string, userPassword?: string, - abortController?: AbortController): Promise { + static async credentialsLogin(userClusterUrl?: string, skipConfirmation = false, context?: string, + userName?: string, userPassword?: string, abortController?: AbortController): Promise { let password: string; const response = await Cluster.requestLoginConfirmation(skipConfirmation); @@ -922,7 +1013,7 @@ export class Cluster extends OpenShiftItem implements Disposable { // If there is saved password for the username - read it password = await TokenStore.getItem('login', username); try { - await Oc.Instance.loginWithUsernamePassword(clusterURL, username, passwd, abortController); + await Oc.Instance.loginWithUsernamePassword(clusterURL, username, passwd, context, abortController); await Cluster.save(username, passwd, password); return await Cluster.loginMessage(clusterURL); } catch (error) { @@ -971,6 +1062,7 @@ export class Cluster extends OpenShiftItem implements Disposable { static async tokenLogin( userClusterUrl: string, skipConfirmation = false, + context?: string, userToken?: string, abortController?: AbortController ): Promise { @@ -1007,7 +1099,7 @@ export class Cluster extends OpenShiftItem implements Disposable { } try { - await Oc.Instance.loginWithToken(clusterURL, ocToken, abortController); + await Oc.Instance.loginWithToken(clusterURL, ocToken, context, abortController); return Cluster.loginMessage(clusterURL); } catch (error) { if (abortController?.signal.aborted) return null; @@ -1051,7 +1143,7 @@ export class Cluster extends OpenShiftItem implements Disposable { } return; } - return Cluster.tokenLogin(apiEndpointUrl, true, clipboard); + return Cluster.tokenLogin(apiEndpointUrl, true, undefined, clipboard); } static async loginUsingClipboardInfo(dashboardUrl: string): Promise { @@ -1066,7 +1158,7 @@ export class Cluster extends OpenShiftItem implements Disposable { } const url = NameValidator.clusterURL(clipboard); const token = NameValidator.getToken(clipboard); - return Cluster.tokenLogin(url, true, token); + return Cluster.tokenLogin(url, true, undefined, token); } static async loginMessage(clusterURL: string): Promise { @@ -1156,6 +1248,6 @@ export class Cluster extends OpenShiftItem implements Disposable { if (!pipelineToken) { return; } - return Cluster.tokenLogin(server, true, pipelineToken); + return Cluster.tokenLogin(server, true, kcu.currentContext, pipelineToken); } -} +} \ No newline at end of file diff --git a/src/openshift/project.ts b/src/openshift/project.ts index a611a77f5..684e7cc2a 100644 --- a/src/openshift/project.ts +++ b/src/openshift/project.ts @@ -7,7 +7,7 @@ import { KubernetesObject } from '@kubernetes/client-node'; import { Disposable, commands, window } from 'vscode'; import { OpenShiftExplorer } from '../explorer'; import { Oc } from '../oc/ocWrapper'; -import { KubeConfigUtils, getNamespaceKind } from '../util/kubeUtils'; +import { KubeConfigInfo, getNamespaceKind } from '../util/kubeUtils'; import { Progress } from '../util/progress'; import { VsCommandError, vsCommand } from '../vscommand'; import OpenShiftItem from './openshiftItem'; @@ -83,8 +83,8 @@ export class Project extends OpenShiftItem implements Disposable { projectName = projectName.trim(); return Oc.Instance.createProject(projectName) .then(() => { - const kcu = new KubeConfigUtils(); - const currentContext = kcu.findContext(kcu.currentContext); + const k8sConfigInfo = new KubeConfigInfo(); + const currentContext = k8sConfigInfo.findContext(k8sConfigInfo.getEffectiveKubeConfig().currentContext); if (currentContext && projectName === currentContext.namespace) { // We have to force refresh on App Explorer in case of the new project name // is the same as the one set in current context (active project) because, diff --git a/src/util/kubeUtils.ts b/src/util/kubeUtils.ts index 7482fbb63..3496c5e84 100644 --- a/src/util/kubeUtils.ts +++ b/src/util/kubeUtils.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ -import { KubeConfig, findHomeDir, loadYaml } from '@kubernetes/client-node'; +import { KubeConfig, loadYaml } from '@kubernetes/client-node'; import { ActionOnInvalid, Cluster, Context, User } from '@kubernetes/client-node/dist/config_types'; import * as fs from 'fs'; +import { dump } from 'js-yaml'; import * as path from 'path'; -import { QuickPickItem, window, workspace } from 'vscode'; +import { QuickPickItem, window } from 'vscode'; import { CommandText } from '../base/command'; import { CliChannel, ExecutionContext } from '../cli'; import { Platform } from './platform'; @@ -21,39 +22,169 @@ function fileExists(file: string): boolean { } } -export class KubeConfigUtils extends KubeConfig { +type KubeConfigEntry = { + path: string, + config?: KubeConfig +}; - public readonly loadingError: any; - constructor() { +class CustomKubeConfig extends KubeConfig { + constructor(ctmContexts: Context[], ctmCurrentContext: string, ctmClusters: Cluster[], ctmUsers: User[]) { super(); - try { - const failOnBrokenEntry: boolean = workspace.getConfiguration('openshiftToolkit') - .get('failOnBrokenKubeConfigEntry'); - const onInvalidEntry = failOnBrokenEntry ? ActionOnInvalid.THROW : ActionOnInvalid.FILTER; - this.loadFromDefault({onInvalidEntry}); - } catch { - throw new Error('Kubernetes configuration cannot be loaded. Please check configuration files for errors and fix them to continue.'); + this.contexts = ctmContexts; + this.clusters = ctmClusters; + this.users = ctmUsers; + this.currentContext = ctmCurrentContext; + } +}; + +export class KubeConfigInfo { + private configs: KubeConfigEntry[]; + private effectiveConfig: KubeConfig; + + constructor() { + this.loadConfigs(); + } + + findHomeDir(): string { + return Platform.getUserHomePath(); + } + + private loadConfigs(): void { + const files: string[] = []; + if (process.env.KUBECONFIG) { + // Convert 'KUBECONFIG' value into a string array + files.push(...process.env.KUBECONFIG.split(path.delimiter)); + } + if (files.length === 0) { + // Use default Kube config in case of 'KUBECONFIG' isn't defiled or empty + files.push(path.join(this.findHomeDir(), '.kube', 'config')); + } + + const entries: KubeConfigEntry[] = []; + for (let i = 0; i < files.length; i++) { + if (fs.existsSync(files[i]) && !entries.find((e: KubeConfigEntry) => e.path === files[i])) { + entries.push( {path: files[i], config: loadKubeConfig(files[i])}); + } + } + this.configs = entries; + } + + public getEffectiveKubeConfig(): KubeConfig { + if (!this.effectiveConfig) { + if (this.configs.length > 0) { + this.effectiveConfig = this.merge(this.configs); + } else { + // Added for compatibility with '@kubernetes/client-node', which returns a 'default' Kube config + // when it can't read the default configuration file + const defaultConfig = new KubeConfig(); + defaultConfig.loadFromDefault(); + this.effectiveConfig = defaultConfig; + } + } + return this.effectiveConfig; + } + + /** + * Returns the map of Kube config path to Context objects compouund the same way as + * the effective Kube config is built (first Kube config file wins for an appearing context) + * + * @returns Map of Kube config path to context objects + */ + public getContextMap(): Map { + const contextMap: Map = new Map(); + for (const ctx of this.getEffectiveKubeConfig().contexts) { + // Find the first Kube config file containing this context + for (const cfg of this.configs) { + if (cfg.config?.contexts?.find((c) => c.name === ctx.name)) { + const newPathContexts = contextMap.has(cfg.path) ? contextMap.get(cfg.path) : []; + newPathContexts.push(ctx); + contextMap.set(cfg.path, newPathContexts); + break; // Stop searching through the rest of configs + } + } + } + return contextMap; + } + + /** + * @returns Map of Kube config path to cluster objects + */ + public getClusterMap(): Map { + const clusterMap: Map = new Map(); + for (const cluster of this.getEffectiveKubeConfig().clusters) { + // Find the first Kube config file containing this cluster + for (const cfg of this.configs) { + if (cfg.config?.clusters?.find((c) => c.name === cluster.name)) { + const newPathClusters = clusterMap.has(cfg.path) ? clusterMap.get(cfg.path) : []; + newPathClusters.push(cluster); + clusterMap.set(cfg.path, newPathClusters); + break; // Stop searching through the rest of configs + } + } } - // k8s nodejs-client ignores all unknown properties, - // so cluster object's proxy-url attribute is not present - // after k8s config loaded + return clusterMap; + } + + /** + * @returns Map of Kube config path to user objects + */ + public getUserMap(): Map { + const userMap: Map = new Map(); + for (const user of this.getEffectiveKubeConfig().users) { + // Find the first Kube config file containing this user + for (const cfg of this.configs) { + if (cfg.config?.users?.find((u) => u.name === user.name)) { + const newPathUsers = userMap.has(cfg.path) ? userMap.get(cfg.path) : []; + newPathUsers.push(user); + userMap.set(cfg.path, newPathUsers); + break; // Stop searching through the rest of configs + } + } + } + return userMap; + } + + public findContext(contextName: string): Context { + return this.getEffectiveKubeConfig()?.contexts?.find((context: Context) => context.name === contextName); + } + + public findCluster(clusterServer: string): Cluster { + return this.getEffectiveKubeConfig()?.clusters?.find((cluster: Cluster) => cluster.server === clusterServer); + } + + public findClusterURL(clusterNameOrURL: string): string { + let clusterObj: Cluster = this.findCluster(clusterNameOrURL); + clusterObj = clusterObj || this.getEffectiveKubeConfig()?.clusters?.find((cluster: Cluster) => cluster.name === clusterNameOrURL); + return clusterObj ? clusterObj.server : undefined; + } + + public findContextForCluster(clusterName: string): Context { + return this.getEffectiveKubeConfig()?.contexts?.find((context: Context) => context.cluster === clusterName); } - findHomeDir() { - return findHomeDir(); + public getClusterUsers(clusterServer: string): QuickPickItem[] { + const currentUser = this.getEffectiveKubeConfig().getCurrentUser(); + const cluster = this.findCluster(clusterServer); + const clusterUsers = this.getEffectiveKubeConfig().users?.filter((item) => cluster && item.name.includes(cluster.name)); + return clusterUsers.map((u: User) => { + const userName = u.name.split('/')[0]; + return { + label: userName === 'kube:admin' ? 'kubeadmin' : userName, + description: u === currentUser ? 'Current Context' : '', + }; + }); } getProxy(contextName: string): string | undefined { if (process.env.KUBECONFIG?.[1]) { const cFiles = process.env.KUBECONFIG.split(path.delimiter).filter(file => file); - //const yaml = for (let i=0; i < cFiles.length; i++) { const proxyUrl = this.getClusterProxyFromFile(cFiles[i], contextName); if (proxyUrl) return proxyUrl; } return; } - const home = this.findHomeDir(); + const home = new KubeConfigInfo().findHomeDir(); if (home) { const config = path.join(home, '.kube', 'config'); if (fileExists(config)) { @@ -65,83 +196,192 @@ export class KubeConfigUtils extends KubeConfig { getClusterProxyFromFile(file: string, contextName: string): string { const fileContent = fs.readFileSync(file, 'utf8'); const yaml: any = loadYaml(fileContent); - const contextObj = yaml.contexts.find( + const contextObj = yaml.contexts?.find( (context) => context.name === contextName); - const clusterObj = yaml.clusters.find( + const clusterObj = yaml.clusters?.find( (cluster) => cluster.name === contextObj?.context?.cluster); return clusterObj?.cluster?.['proxy-url']; } - public getServers(): QuickPickItem[] { - const currentCluster = this.getCurrentCluster(); - const clusters = this.clusters || []; - const qpItems = clusters.map((c: Cluster) => ({ - label: c.server, - description: currentCluster && c.name === currentCluster.name ? 'Current Context' : '', - })); - const filterMap = new Set(); - return qpItems.filter((item) => { - const notDuplicate = !filterMap.has(item.label); - if(notDuplicate) { - filterMap.add(item.label); + public equalsToCurrentContext(contextName:string, cluster: string, namespace: string, user: string): boolean { + const currentContext = this.findContext(this.getEffectiveKubeConfig().currentContext); + if (!currentContext) return false; + if (currentContext.name !== contextName) return false; + if (currentContext.cluster !== cluster) return false; + if (currentContext.namespace !== namespace) return false; + if (currentContext.user !== user) return false; + return true; + } + + public extractProjectNameFromCurrentContext():string { + const currentContextName = this.getEffectiveKubeConfig().currentContext; + return extractProjectNameFromContextName(currentContextName); + } + + private merge(configs: KubeConfigEntry[]): KubeConfig { + const mergedContexts = this.mergeContexts(configs); + const mergedClusters = this.mergeClusters(configs); + const mergedUsers = this.mergeUsers(configs); + const contextPreferences = this.contextPreference(configs); + let mergedCurrentContext = undefined; + for (let i = 0; i < contextPreferences.length; i++) { + if (mergedContexts.find((c) => c.name === contextPreferences[i])) { + mergedCurrentContext = contextPreferences[i]; + break; } - return notDuplicate; - }); + } + if (!mergedCurrentContext) { + window.showWarningMessage('Kube cinfiguration doesn\'t define any current context value.'); + } + return new CustomKubeConfig(mergedContexts, mergedCurrentContext, mergedClusters, mergedUsers); } - public getClusterUsers(clusterServer: string): QuickPickItem[] { - const currentUser = this.getCurrentUser(); - const cluster = this.findCluster(clusterServer); - const users = this.getUsers(); - const clusterUsers = users.filter((item) => cluster && item.name.includes(cluster.name)); - return clusterUsers.map((u: User) => { - const userName = u.name.split('/')[0]; - return { - label: userName === 'kube:admin' ? 'kubeadmin' : userName, - description: u === currentUser ? 'Current Context' : '', - }; - }); + private mergeClusters(configs: KubeConfigEntry[]): Cluster[] { + const mergedClusters: Cluster[] = []; + + // process Kube configs in inverse order, so that the first Kube config has precedence + for (let i = configs.length - 1; i >= 0; i--) { + if (configs[i].config?.clusters) { + for (const cluster of configs[i].config.clusters) { + if (cluster) { + const index = mergedClusters.findIndex((c) => c.name === cluster.name); + if (index >= 0) { + mergedClusters[index] = cluster; + } else { + mergedClusters.push(cluster); + } + } + } + } + } + return mergedClusters; } - public findCluster(clusterServer: string): Cluster { - return this.getClusters().find((cluster: Cluster) => cluster.server === clusterServer); + private mergeContexts(configs: KubeConfigEntry[]): Context[] { + const mergedContexts: Context[] = []; + + // process Kube configs in inverse order, so that the first Kube config has precedence + for (let i = configs.length - 1; i >= 0; i--) { + if (configs[i].config?.contexts) { + for (const ctx of configs[i].config.contexts) { + if (ctx) { + const index = mergedContexts.findIndex((c) => c.name === ctx.name); + if (index >= 0) { + mergedContexts[index] = ctx; + } else { + mergedContexts.push(ctx); + } + } + } + } + } + return mergedContexts; } - public findClusterURL(clusterNameOrURL: string): string { - let clusterObj: Cluster = this.findCluster(clusterNameOrURL); - clusterObj = clusterObj || this.clusters.find((cluster: Cluster) => cluster.name === clusterNameOrURL); - return clusterObj ? clusterObj.server : undefined; + private mergeUsers(configs: KubeConfigEntry[]): User[] { + const mergedUsers: User[] = []; + + // process Kube configs in inverse order, so that the first Kube config has precedence + for (let i = configs.length - 1; i >= 0; i--) { + if (configs[i].config?.users) { + for (const usr of configs[i].config.users) { + if (usr) { + const index = mergedUsers.findIndex((c) => c.name === usr.name); + if (index >= 0) { + mergedUsers[index] = usr; + } else { + mergedUsers.push(usr); + } + } + } + } + } + return mergedUsers; } - public findContext(contextName: string): Context { - return this.getContexts().find((context: Context) => context.name === contextName); + /** + * Returns the array of 'current-context''s constructed from the merged Kube configs. + * The first array element will be the most prefered context as it's been constructed from the first + * Kube config appeared during the merge. + */ + private contextPreference(configs: KubeConfigEntry[]): string[] { + const mergedCurrentContexts: string[] = []; + for (let i = 0; i < configs.length; i++) { + if (configs[i].config?.currentContext && configs[i].config.currentContext.trim().length > 0) { + if (!mergedCurrentContexts.find((cc) => cc === configs[i].config.currentContext)) { + mergedCurrentContexts.push(configs[i].config.currentContext); + } + } + } + return mergedCurrentContexts; } - public findContextForCluster(clusterName: string): Context { - return this.getContexts().find((context: Context) => context.cluster === clusterName); + /** + * Finds the Kube Config path for the specified context name + * + * @param context Context name to search Kube config path for, if not specified the current context will be used + * to search Kube config path + * + * @returns `string` value of the Kube Config file where the current context is defined or `undefined` + */ + public static getContextConfigPath(context: string): string | undefined { + const latestConfigInfo = new KubeConfigInfo(); + const contextEntries = [...latestConfigInfo.getContextMap()]; + const [configPath] = contextEntries.find(([key, value]) => value.find((c) => c.name === context)); + return configPath; } - public extractProjectNameFromCurrentContext():string { - const currentContextName = this.getCurrentContext(); - return this.extractProjectNameFromContextName(currentContextName); + /** + * Finds the Kube Config path for the current context + * + * @returns `string` value of the Kube Config file where the current context is defined or `undefined` + */ + public static getCurrentContextConfigPath(): string | undefined { + const latestConfigInfo = new KubeConfigInfo(); + const currentContext = latestConfigInfo.getEffectiveKubeConfig().currentContext; + return this.getContextConfigPath(currentContext); } - public extractProjectNameFromContextName(contextName: string):string { - if (contextName && contextName.includes('/') && !contextName.startsWith('/')) { - return contextName.split('/')[0]; - } - return undefined; + /** + * Finds the Kube Config path for the main (first or a default one) context + * + * @returns `string` value of the Kube Config file where the current context is defined or `undefined` + */ + public static getMainContextConfigPath(): string | undefined { + return new KubeConfigInfo().configs[0].path; } - public equalsToCurrentContext(contextName:string, cluster: string, namespace: string, user: string): boolean { - const currentContext = this.findContext(this.currentContext); - if (!currentContext) return false; + /** + * Returns the list of all the Kube Config paths declared in '$KUBECONFIG' env. variable or the default one + * + * @returns An array of the Kube Config file paths or `undefined` + */ + public static getAllConfigPaths(): string[] { + return new KubeConfigInfo().configs.map((c) => c.path); + } - if (currentContext.name !== contextName) return false; - if (currentContext.cluster !== cluster) return false; - if (currentContext.namespace !== namespace) return false; - if (currentContext.user !== user) return false; - return true; + public dumpEffectiveKubeConfig(): string { + return dump(this.sanitizeKubeConfig(this.getEffectiveKubeConfig()), { sortKeys: true, lineWidth: -1 }); + } + + private sanitizeKubeConfig(config: KubeConfig): any { + const sanitizedConfig = JSON.parse(config.exportConfig()); // Deep copy to avoid mutating original + + // Function to recursively sanitize values + function sanitize(obj: any) { + for (const key in obj) { + if (obj[key] && typeof obj[key] === 'object') { + sanitize(obj[key]); + } else if (typeof obj[key] === 'string' && key.includes('token')) { + obj[key] = '[REDACTED]'; // Replace token-like strings with [REDACTED] + } else if (typeof obj[key] === 'string' && key.includes('data')) { + obj[key] = '[DATA+OMITTED]'; // Replace token-like strings with [DATA+OMITTED] + } + } + } + + sanitize(sanitizedConfig); + return sanitizedConfig; } } @@ -159,7 +399,7 @@ export function getKubeConfigFiles(): string[] { const configuredFiles: string[] = process.env.KUBECONFIG.split(path.delimiter); const filesThatExist: string[] = []; for (const configFile of configuredFiles) { - if (fs.existsSync(configFile)) { + if (fs.existsSync(configFile) && !filesThatExist.includes(configFile)) { filesThatExist.push(configFile); } } @@ -168,37 +408,12 @@ export function getKubeConfigFiles(): string[] { const defaultKubeConfigFile = path.join(Platform.getUserHomePath(), '.kube', 'config'); if (!fs.existsSync(defaultKubeConfigFile)) { - try { - fs.appendFileSync(defaultKubeConfigFile, 'apiVersion: v1'); // Create Kube Config with minimal content - } catch (err) { - void window.showErrorMessage(`Kubernetes configuration file cannot be created at '${defaultKubeConfigFile}': ${err}`); - } + void window.showErrorMessage(`Default Kubernetes configuration file doesn't exist at '${defaultKubeConfigFile}'`); } return [defaultKubeConfigFile]; } -/** - * If there are multiple kube config files set, force the user to pick one to use. - */ -export async function setKubeConfig(): Promise { - const kubeConfigFiles = getKubeConfigFiles(); - if (kubeConfigFiles.length > 1) { - let selectedFile; - while(!selectedFile) { - try { - const potentialSelection = await window.showQuickPick(kubeConfigFiles, { canPickMany: false, placeHolder: 'VSCode OpenShift only supports using one kube config. Please select which one to use.' }); - if (potentialSelection) { - selectedFile = potentialSelection; - } - } catch { - // do nothing - } - } - process.env.KUBECONFIG = selectedFile; - } -} - export async function isOpenShiftCluster(executionContext?: ExecutionContext): Promise { try { const stdout = await CliChannel.getInstance().executeSyncTool(new CommandText('oc', 'api-versions'), { timeout: 5000 }, executionContext); @@ -218,3 +433,81 @@ export async function getNamespaceKind(executionContext?: ExecutionContext): Pro } return result; } + +export function extractProjectNameFromContextName(contextName: string):string { + if (contextName && contextName.includes('/') && !contextName.startsWith('/')) { + return contextName.split('/')[0]; + } + return undefined; +} + +export function toKubeContext(context: Context) { + return { + name: context.name, + context: { + cluster: context.cluster, + user: context.user, + ...(context.namespace ? { namespace: context.namespace } : {}) + } + }; +} + +export function toKubeCluster(cluster: Cluster) { + const { name, caFile, caData, skipTLSVerify, tlsServerName, ...rest } = cluster; + return { + name, + cluster: { + ...rest, + ...(caFile ? { 'certificate-authority': caFile } : {}), + ...(caData ? { 'certificate-authority-data': caData } : {}), + ...(skipTLSVerify !== undefined ? { 'insecure-skip-tls-verify': skipTLSVerify } : {}), + ...(tlsServerName ? { 'tls-server-name': tlsServerName } : {}) + } + }; +} + +export function toKubeUser(user: User) { + const { name, certFile, certData, keyFile, keyData, ...rest } = user; + return { + name, + user: { + ...rest, + ...(certFile ? { 'client-certificate': certFile } : {}), + ...(certData ? { 'client-certificate-data': certData } : {}), + ...(keyFile ? { 'client-key': keyFile } : {}), + ...(keyData ? { 'client-key-data': keyData } : {}), + } + }; +} + +export function serializeKubeConfig(kc: KubeConfig): string { + const fullConfig = { + apiVersion: 'v1', + clusters: kc?.clusters?.map(toKubeCluster), + contexts: kc?.contexts?.map(toKubeContext), + 'current-context': kc?.getCurrentContext(), + kind: 'Config', + preferences: (kc as any)?.preferences ?? {}, + users: kc?.users?.map(toKubeUser), + }; + + return dump(fullConfig, { indent: 2, lineWidth: -1, styles: { '!!str': 'plain' } }); +} + +const _kubeConfigErrorCache: Map = new Map(); // Cache for KC loading errors, allows to skip duplicate reporting +export function loadKubeConfig(path: string): KubeConfig { + const kc = new KubeConfig(); + try { + kc.loadFromFile(path, { onInvalidEntry: ActionOnInvalid.FILTER }); + _kubeConfigErrorCache.delete(path); + } catch (__err) { + const cachedError = _kubeConfigErrorCache.get(path); + if (cachedError) { + if (!cachedError || cachedError !== __err) { + _kubeConfigErrorCache.set(path, __err); + window.showErrorMessage(`An error occured while loding KubeConfig from "${path}": ${__err}`); + } + } + } + return kc; +} \ No newline at end of file diff --git a/src/util/utils.ts b/src/util/utils.ts index 07873ec0f..5cf57d115 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -22,7 +22,13 @@ import { decompress as decompressOriginal } from 'targz'; * @returns An absolute path to specified image */ export function imagePath(imagePath: string): string { - return path.join(__dirname, '../../images/', imagePath); + // The module path can be either 'out/src/util' or 'out/util', so we should + // construct the path to 'images' as '.../.../.../images' or '../../images' + // according to the way the extension is executed. + // + let baseDir = path.join(__dirname, '..', '..'); + baseDir = path.parse(baseDir).name === 'out' ? path.dirname(baseDir) : baseDir; + return path.join(baseDir, 'images', imagePath); } // The following wrappers are needed for unit tests due to diff --git a/test/ui/suite/kubernetesContext.ts b/test/ui/suite/kubernetesContext.ts index 265952aa8..be8a06619 100644 --- a/test/ui/suite/kubernetesContext.ts +++ b/test/ui/suite/kubernetesContext.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ import { expect } from 'chai'; +import * as fs from 'fs-extra'; +import * as yml from 'js-yaml'; import { ActivityBar, EditorView, @@ -13,18 +15,17 @@ import { TreeItem, VSBrowser, ViewSection, + WelcomeContentButton, Workbench, - before, - beforeEach, after, + before, + beforeEach } from 'vscode-extension-tester'; import { activateCommand } from '../common/command-activator'; import { itemExists, notificationExists } from '../common/conditions'; import { ACTIONS, INPUTS, NOTIFICATIONS, VIEWS } from '../common/constants'; -import { collapse } from '../common/overdrives'; import { addKubeContext, getKubeConfigContent, getKubeConfigPath } from '../common/kubeConfigUtils'; -import * as fs from 'fs-extra'; -import * as yml from 'js-yaml'; +import { collapse } from '../common/overdrives'; export function kubernetesContextTest(isOpenshiftCluster: boolean) { describe('Kubernetes Context', function () { @@ -35,6 +36,7 @@ export function kubernetesContextTest(isOpenshiftCluster: boolean) { let explorer: ViewSection; let quickPicks: QuickPickItem[]; + const allQuickPicksLabels: string[] = []; const allQuickPicksTexts: string[] = []; const kubeCopy = `${getKubeConfigPath()}-cp`; @@ -95,7 +97,7 @@ export function kubernetesContextTest(isOpenshiftCluster: boolean) { await explorer.expand(); const welcomeContent = await explorer.findWelcomeContent(); - const buttons = await welcomeContent.getButtons(); + const buttons: WelcomeContentButton[] = await welcomeContent.getButtons(); const contextButton = buttons[1]; await contextButton.click(); @@ -106,14 +108,19 @@ export function kubernetesContextTest(isOpenshiftCluster: boolean) { expect(quickPicks).is.not.empty; for (let i = 0; i < quickPicks.length; i++) { + allQuickPicksLabels[i] = await quickPicks[i].getLabel(); allQuickPicksTexts[i] = await quickPicks[i].getText(); } const quickPickText = allQuickPicksTexts[0]; - const project = quickPickText.match(/\w+/)[0]; - const projectName = project.split('on')[0]; - await inputBox.selectQuickPick(projectName); + // Find project name for QuickPick Item #0 + // Example quickPick text: [... Project: test-namespace, User: kind-kind] + const project = quickPickText.split(',')[0]; // Left: [... Project: test-namespace] + const projectName = project.split(':')[1].trim(); // Left [test-namespace] + + // Select QuickPick Item #0 + await inputBox.selectQuickPick(allQuickPicksLabels[0]); if (isOpenshiftCluster) { inputBox = await InputBox.create(); @@ -122,6 +129,7 @@ export function kubernetesContextTest(isOpenshiftCluster: boolean) { await inputBox.confirm(); } + // Check project name appeared on the App. Tree const clusterNode = (await itemExists(clusterName, explorer)) as TreeItem; await clusterNode.expand(); await itemExists(projectName, explorer); @@ -130,8 +138,10 @@ export function kubernetesContextTest(isOpenshiftCluster: boolean) { it('Switch context', async function () { this.timeout(20_000); - const quickPickText = allQuickPicksTexts[1]; - const projectName = quickPickText.split('on')[0]; + const quickPickText = allQuickPicksTexts[1]; // Use the second context of two + // Example quickPick text: [... Project: test-namespace, User: kind-kind] + const project = quickPickText.split(',')[0]; // Left: [... Project: test-namespace] + const projectName = project.split(':')[1].trim(); // Left [test-namespace] await collapse(explorer); await explorer.expand(); @@ -140,7 +150,7 @@ export function kubernetesContextTest(isOpenshiftCluster: boolean) { await action.click(); const inputBox = await InputBox.create(); - await inputBox.selectQuickPick(projectName); + await inputBox.selectQuickPick(allQuickPicksLabels[1]); // Swtich to the second context of two await itemExists(projectName, explorer); }); diff --git a/test/unit/k8s/console.test.ts b/test/unit/k8s/console.test.ts index a2601b0aa..ba9fbc3f7 100644 --- a/test/unit/k8s/console.test.ts +++ b/test/unit/k8s/console.test.ts @@ -9,7 +9,7 @@ import sinonChai from 'sinon-chai'; import * as vscode from 'vscode'; import { CliChannel } from '../../../src/cli'; import { Console } from '../../../src/k8s/console'; -import { KubeConfigUtils } from '../../../src/util/kubeUtils'; +import { KubeConfigInfo } from '../../../src/util/kubeUtils'; const {expect} = chai; chai.use(sinonChai); @@ -19,7 +19,8 @@ suite('K8s/console', () => { let cliExecStub: sinon.SinonStub; let commandStub: any; - const k8sConfig = new KubeConfigUtils(); + const k8sConfigInfo = new KubeConfigInfo(); + const k8sConfig = k8sConfigInfo.getEffectiveKubeConfig(); const project = (k8sConfig.contexts).find((ctx) => ctx.name === k8sConfig.currentContext).namespace; const context = { diff --git a/test/unit/openshift/cluster.test.ts b/test/unit/openshift/cluster.test.ts index 0fde11249..29edec895 100644 --- a/test/unit/openshift/cluster.test.ts +++ b/test/unit/openshift/cluster.test.ts @@ -134,7 +134,7 @@ suite('Openshift/Cluster', function() { test('logins to new cluster if user answer yes to a warning', async () => { requireLoginStub.resolves(false); infoStub.resolves('Yes'); - const result = await Cluster.credentialsLogin(false, testUrl); + const result = await Cluster.credentialsLogin(testUrl, false); expect(result).equals(`Successfully logged in to '${testUrl}'`); }); @@ -155,7 +155,7 @@ suite('Openshift/Cluster', function() { test('doesn\'t ask to save password if old and new passwords are the same', async () => { infoStub.resolves('Yes'); sandbox.stub(TokenStore, 'getItem').resolves(password); - const result = await Cluster.credentialsLogin(false, testUrl); + const result = await Cluster.credentialsLogin(testUrl, false); expect(result).equals(`Successfully logged in to '${testUrl}'`); }); @@ -178,7 +178,7 @@ suite('Openshift/Cluster', function() { quickPickStub.onFirstCall().resolves({label: testUser}); quickPickStub.onSecondCall().resolves({label: 'Credentials'}); inputStub.resolves('password'); - const status = await Cluster.credentialsLogin(false, testUrl); + const status = await Cluster.credentialsLogin(testUrl, false); expect(status).equals(`Successfully logged in to '${testUrl}'`); expect(passwordLoginStub).calledOnceWith(testUrl, testUser, password); @@ -203,7 +203,7 @@ suite('Openshift/Cluster', function() { execStub.resolves(errorData); let expectedErr: { message: any }; try { - await Cluster.credentialsLogin(true); + await Cluster.credentialsLogin(undefined, true); } catch (err) { expectedErr = err; } diff --git a/test/unit/util/kubeUtils.test.ts b/test/unit/util/kubeUtils.test.ts index 4f83d76ac..1178447ca 100644 --- a/test/unit/util/kubeUtils.test.ts +++ b/test/unit/util/kubeUtils.test.ts @@ -7,7 +7,7 @@ import * as chai from 'chai'; import * as path from 'path'; import * as sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { KubeConfigUtils } from '../../../src/util/kubeUtils'; +import { KubeConfigInfo } from '../../../src/util/kubeUtils'; const {expect} = chai; chai.use(sinonChai); @@ -29,14 +29,17 @@ suite('K8s Configuration Utility', () => { sandbox.stub(process, 'env').value({ 'KUBECONFIG': [path.join(configDir, 'config'), path.join(configDir, 'config1')].join(path.delimiter) }); - const kc = new KubeConfigUtils(); - expect(kc.getProxy('context-cluster5')).is.not.undefined; - expect(kc.getProxy('context-cluster4')).is.undefined; + const k8sConfigInfo = new KubeConfigInfo(); + expect(k8sConfigInfo.getProxy('context-cluster5')).is.not.undefined; + expect(k8sConfigInfo.getProxy('context-cluster4')).is.undefined; }); test('loads ~/.kube/config', () => { - sandbox.stub(KubeConfigUtils.prototype, 'findHomeDir').returns(homeDir); - const kc = new KubeConfigUtils(); - expect(kc.getProxy('context-cluster1')).is.not.undefined; + sandbox.stub(process, 'env').value({ + 'KUBECONFIG': undefined // Make sure the `KUBECONFIG` env. variable is unset, otherwise, the `findHomeDir()` will not be invoked + }); + sandbox.stub(KubeConfigInfo.prototype, 'findHomeDir').returns(homeDir); + const k8sConfigInfo = new KubeConfigInfo(); + expect(k8sConfigInfo.getProxy('context-cluster1')).is.not.undefined; }) }); \ No newline at end of file