From 382d4f3eb6f82d03ee1b011355210fcddbfc8741 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Mon, 13 May 2024 17:48:10 +0200 Subject: [PATCH] Improve set active or create new project/namespace workflow - Now, when switching projects/namespaces, one can manually type in a project name to be set as an active one. - No more 'Missing project/namespace' item for a project/namespace that doesn't exist on a cluster. This allows working normally on clusters with restrictions on list for projects. Fixes: #3999 - The project listing is fixed, so annotated projects are shown now Fixes: #4101 - For a Sandbox cluster a project which name contains current user name is used as a default one when logging in (A follow up to #4109) Issue: #4080 (?) Improved the detection whether we're logged in to a cluster or not. Signed-off-by: Victor Rubezhny --- package.json | 12 +-- src/explorer.ts | 80 ++++++++++------- src/oc/ocWrapper.ts | 134 ++++++++++++++++++++++------- src/openshift/project.ts | 37 +++++--- src/util/loginUtil.ts | 12 +-- test/integration/ocWrapper.test.ts | 9 ++ 6 files changed, 197 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index f9ecf29b8..c9bc34fce 100644 --- a/package.json +++ b/package.json @@ -1720,12 +1720,12 @@ }, { "command": "openshift.project.set", - "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && canCreateNamespace && isOpenshiftCluster", + "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && isOpenshiftCluster", "group": "c1@2" }, { "command": "openshift.namespace.set", - "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && canCreateNamespace && !isOpenshiftCluster", + "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && !isOpenshiftCluster", "group": "c1@2" }, { @@ -1750,12 +1750,12 @@ }, { "command": "openshift.project.set", - "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && canCreateNamespace && isOpenshiftCluster", + "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && isOpenshiftCluster", "group": "p3@1" }, { "command": "openshift.namespace.set", - "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && canCreateNamespace && !isOpenshiftCluster", + "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && !isOpenshiftCluster", "group": "p3@1" }, { @@ -1770,12 +1770,12 @@ }, { "command": "openshift.project.set", - "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && canCreateNamespace && isOpenshiftCluster", + "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && isOpenshiftCluster", "group": "inline" }, { "command": "openshift.namespace.set", - "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && canCreateNamespace && !isOpenshiftCluster", + "when": "view == openshiftProjectExplorer && viewItem =~ /openshift.project.*/i && !isOpenshiftCluster", "group": "inline" }, { diff --git a/src/explorer.ts b/src/explorer.ts index 945bf21ce..df3e8b52d 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -76,6 +76,13 @@ async function createOrSetProjectItem(projectName: string): Promise, Disposable { private static instance: OpenShiftExplorer; @@ -138,14 +145,23 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos private static generateOpenshiftProjectContextValue(namespace: string): Thenable { const contextValue = `openshift.project.${namespace}`; - return Oc.Instance.canDeleteNamespace(namespace) - .then(result => (result ? `${contextValue}.can-delete` : contextValue)); + const allTrue = arr => arr.every(Boolean); + + return Promise.all([ + Oc.Instance.canDeleteNamespace(namespace), + Oc.Instance.getProjects(true) + .then((clusterProjects) => { + const existing = clusterProjects.find((project) => project.name === namespace); + return existing !== undefined; + }) + ]) + .then(result => (allTrue(result) ? `${contextValue}.can-delete` : contextValue)); } // eslint-disable-next-line class-methods-use-this async getTreeItem(element: ExplorerItem): Promise { - if ('command' in element) { + if ('command' in element || ('label' in element && 'iconPath' in element)) { return element; } @@ -290,6 +306,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos if (this.kubeContext) { const config = getKubeConfigFiles(); void commands.executeCommand('setContext', 'canCreateNamespace', await Oc.Instance.canCreateNamespace()); + void commands.executeCommand('setContext', 'canListNamespaces', await Oc.Instance.canListNamespaces()); result.unshift({ label: process.env.KUBECONFIG ? 'Custom KubeConfig' : 'Default KubeConfig', description: config.join(':') }) } } @@ -299,7 +316,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos OpenShiftExplorer.getInstance().onDidChangeContextEmitter.fire(new KubeConfigUtils().currentContext); } else if ('name' in element) { // we are dealing with context here // user is logged into cluster from current context - // and project should be show as child node of current context + // and project should be shown as child node of current context // there are several possible scenarios // (1) there is no namespace set in context and default namespace/project exists // * example is kubernetes provisioned with docker-desktop @@ -309,31 +326,23 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos // (3) there is namespace set in context and namespace exists in the cluster // (4) there is namespace set in context and namespace does not exist in the cluster const namespaces = await Oc.Instance.getProjects(); - if (this.kubeContext.namespace) { - if (namespaces.find(item => item.name === this.kubeContext.namespace)) { - result = [{ - kind: 'project', - metadata: { - name: this.kubeContext.namespace, - }, - } as KubernetesObject]; - } else { - result = [await createOrSetProjectItem(this.kubeContext.namespace)]; - } + // Actually 'Oc.Instance.getProjects()' takes care of setting up at least one project as + // an active project, so here after it's enough just to search the array for it. + // The only case where there could be no active project set is empty projects array. + let active = namespaces.find((project) => project.active); + if (!active) active = namespaces.find(item => item?.name === 'default'); + + // find active or default namespace + if (active) { + result = [{ + kind: 'project', + metadata: { + name: active.name, + }, + } as KubernetesObject] } else { - // get list of projects or namespaces - // find default namespace - if (namespaces.find(item => item?.name === 'default')) { - result = [{ - kind: 'project', - metadata: { - name: 'default', - }, - } as KubernetesObject] - } else { - const projectName = this.kubeConfig.extractProjectNameFromCurrentContext() || 'default'; - result = [await createOrSetProjectItem(projectName)]; - } + const projectName = this.kubeConfig.extractProjectNameFromCurrentContext() || 'default'; + result = [await createOrSetProjectItem(projectName)]; } // The 'Create Service' menu visibility @@ -369,7 +378,12 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos } } } else if ('kind' in element && element.kind === 'Deployment') { - return await this.getPods(element); + try { + const pods = await Oc.Instance.getKubernetesObjects('pods'); + return pods.filter((pod) => pod.metadata.name.indexOf(element.metadata.name) !== -1); + } catch { + return [ couldNotGetItem(element.kind, this.kubeConfig.getCluster(this.kubeContext.cluster)?.server) ]; + } } else if ('kind' in element && element.kind === 'project') { const deployments = { kind: 'deployments', @@ -446,7 +460,7 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos } } else if ('kind' in element) { const collectableServices: CustomResourceDefinitionStub[] = await this.getServiceKinds(); - let collections: KubernetesObject[] | Helm.HelmRelease[]; + let collections: KubernetesObject[] | Helm.HelmRelease[] | ExplorerItem[]; switch (element.kind) { case 'helmReleases': collections = await Helm.getHelmReleases(); @@ -457,7 +471,11 @@ export class OpenShiftExplorer implements TreeDataProvider, Dispos } break; default: - collections = await Oc.Instance.getKubernetesObjects(element.kind); + try { + collections = await Oc.Instance.getKubernetesObjects(element.kind); + } catch { + collections = [ couldNotGetItem(element.kind, this.kubeConfig.getCluster(this.kubeContext.cluster)?.server) ]; + } break; } const toCollect = [ diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index 77275b056..85e0e114d 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -200,6 +200,25 @@ export class Oc { return false; } + /** + * Returns true if the current user is authorized to list namespaces on the cluster, and false otherwise. + * + * @returns true if the current user is authorized to list namespaces on the cluster, and false otherwise + */ + public async canListNamespaces(): Promise { + try { + const result = await CliChannel.getInstance().executeTool( + new CommandText('oc', 'auth can-i list projects'), + ); + if (result.stdout === 'yes') { + return true; + } + } catch { + //ignore + } + return false; + } + /** * Returns true if the current user is authorized to delete a namespace on the cluster, and false otherwise. * @@ -504,8 +523,9 @@ export class Oc { } } - public async getProjects(): Promise { - return this._listProjects(); + public async getProjects(onlyFromCluster: boolean = false): Promise { + return this._listProjects() + .then((projects) => onlyFromCluster ? projects : this.fixActiveProject(projects)); } /** @@ -514,51 +534,103 @@ export class Oc { * @returns the active project or null if no project is active */ public async getActiveProject(): Promise { - const projects = await this._listProjects(); - if (!projects.length) { - return null; - } - let activeProject = projects.find((project) => project.active); - if (activeProject) return activeProject.name; + return this._listProjects() + .then((projects) => { + const fixedProjects = this.fixActiveProject(projects); + const activeProject = fixedProjects.find((project) => project.active); + return activeProject ? activeProject.name : null; + }); + } - // If not found - use Kube Config current context or 'default' + /** + * Fixes the projects array by marking up an active project (if not set) + * by the following rules: + * - If there is only one single project - mark it as active + * - If there is already at least one project marked as active - return the projects "as is" + * - If Kube Config's current context has a namespace set - find an according project + * and mark it as active + * - [fixup for Sandbox cluster] Get Kube Configs's curernt username and try finding a project, + * which name is partially created from that username - if found, treat it as an active project + * - Try a 'default' as a project name, if found - use it as an active project name + * - Use first project as active + * + * @returns The array of Projects with at least one project marked as an active + */ + public fixActiveProject(projects: Project[]): Project[] { const kcu = new KubeConfigUtils(); const currentContext = kcu.findContext(kcu.currentContext); + + let fixedProjects = projects.length ? projects : []; + let activeProject = undefined; + if (currentContext) { - const active = currentContext.namespace || 'default'; - activeProject = projects.find((project) => project.name ===active); + // Try Kube Config current context to find existing active project + if (currentContext.namespace) { + activeProject = fixedProjects.find((project) => project.name === currentContext.namespace); + if (activeProject) { + activeProject.active = true; + return fixedProjects; + } + } + + // [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(); + if (currentUser) { + const projectPrefix = currentUser.name.substring(0, currentUser.name.indexOf('/')); + if (projectPrefix.length > 0) { + activeProject = fixedProjects.find((project) => project.name.includes(projectPrefix)); + if (activeProject) { + activeProject.active = true; + void Oc.Instance.setProject(activeProject.name); + return fixedProjects; + } + } + } + + // Add Kube Config current context to the proect list for cases where + // projects/namespaces cannot be listed due to the cluster config restrictions + // (such a project/namespace can be set active manually) + if (currentContext.namespace) { + fixedProjects = [ + { + name: currentContext.namespace, + active: true + }, + ...projects + ] + void Oc.Instance.setProject(currentContext.namespace); + return fixedProjects; + } + } + + // Try a 'default' as a project name, if found - use it as an active project name + activeProject = fixedProjects.find((project) => project.name === 'default'); + if (activeProject) { + activeProject.active = true; + return fixedProjects; } - return activeProject ? activeProject.name : null; + + // Set the first available project as active + if (fixedProjects.length > 0) { + fixedProjects[0].active = true; + void Oc.Instance.setProject(fixedProjects[0].name); + } + + return fixedProjects; } private async _listProjects(): Promise { - const onlyOneProject = 'you have one project on this server:'; const namespaces: Project[] = []; return await CliChannel.getInstance().executeTool( - new CommandText('oc', 'projects') + new CommandText('oc', 'projects -q') ) .then( (result) => { const lines = result.stdout && result.stdout.split(/\r?\n/g); for (let line of lines) { line = line.trim(); if (line === '') continue; - if (line.toLocaleLowerCase().startsWith(onlyOneProject)) { - const matches = line.match(/You\shave\sone\sproject\son\sthis\sserver:\s"([a-zA-Z0-9]+[a-zA-Z0-9.-]*)"./); - if (matches) { - namespaces.push({name: matches[1], active: true}); - break; // No more projects are to be listed - } - } else { - const words: string[] = line.split(' '); - if (words.length > 0 && words.length <= 2) { - // The list of projects may have eithe 1 (project name) or 2 words - // (an asterisk char, indicating that the project is active, and project name). - // Otherwise, it's either a header or a footer text - const active = words.length === 2 && words[0].trim() === '*'; - const projectName = words[words.length - 1] // The last word of array - namespaces.push( {name: projectName, active }); - } - } + namespaces.push( {name: line, active: false }); } return namespaces; }) diff --git a/src/openshift/project.ts b/src/openshift/project.ts index 368e513ab..c8c93ac23 100644 --- a/src/openshift/project.ts +++ b/src/openshift/project.ts @@ -19,25 +19,42 @@ export class Project extends OpenShiftItem { static async set(): Promise { let message: string = null; const kind = await getNamespaceKind(); + const canCreateProjects = await Oc.Instance.canCreateNamespace(); + const canListProjects = await Oc.Instance.canListNamespaces(); + const createNewProject = { label: `Create new ${kind}`, description: `Create new ${kind} and make it active` }; + const manuallySetProject = { + label: `Manually set active ${kind}`, + description: `Type in ${kind} name and make it active` + }; const projectsAndCreateNew = Oc.Instance .getProjects() // - .then((projects) => [ - createNewProject, - ...projects.map((project) => ({ - label: project.name, - description: project.active ? 'Currently active': '', - })), - ]); + .then((projects) => { + const items = []; + if (canCreateProjects) { + items.push(createNewProject); + } + items.push(manuallySetProject); + if (canListProjects) { + items.push(...projects.map((project) => ({ + label: project.name, + description: project.active ? 'Currently active': '', + }))); + } + return items; + }); const selectedItem = await window.showQuickPick(projectsAndCreateNew, {placeHolder: `Select ${kind} to activate or create new one`}); if (!selectedItem) return null; if (selectedItem === createNewProject) { await commands.executeCommand('openshift.project.create'); } else { - const projectName = selectedItem.label; + const projectName = selectedItem === manuallySetProject ? + await Project.getProjectName(`${kind} name`, new Promise((resolve) => {resolve([])})) : selectedItem.label; + if (!projectName) return null; + await Oc.Instance.setProject(projectName); OpenShiftExplorer.getInstance().refresh(); Project.serverlessView.refresh(); @@ -50,7 +67,7 @@ export class Project extends OpenShiftItem { @vsCommand('openshift.namespace.create') static async create(): Promise { const kind = await getNamespaceKind(); - const projectList = Oc.Instance.getProjects(); + const projectList = Oc.Instance.getProjects(true); let projectName = await Project.getProjectName(`${kind} name`, projectList); if (!projectName) return null; projectName = projectName.trim(); @@ -76,7 +93,7 @@ export class Project extends OpenShiftItem { static async delFromPalette(): Promise { const kind = await getNamespaceKind(); const projects = Oc.Instance - .getProjects() // + .getProjects(true) // Get only projects existing on cluster .then((projects) => [ ...projects.map((project) => ({ label: project.name, diff --git a/src/util/loginUtil.ts b/src/util/loginUtil.ts index d1e5511cb..40b1fd2fc 100644 --- a/src/util/loginUtil.ts +++ b/src/util/loginUtil.ts @@ -35,15 +35,9 @@ export class LoginUtil { if (serverURI && !(`${serverCheck}`.toLowerCase().includes(serverURI.toLowerCase()))) return true; return await CliChannel.getInstance().executeSyncTool( - new CommandText('oc', 'whoami'), { timeout: 5000 }) - .then((user) => false) // Active user is set - no need to login - .catch((error) => { - if (!error.stderr) return true; // Error with no reason - require to login - - // if reason is "forbidden" or not determined - require to login, otherwise - no need to login - const matches = error.stderr.match(/Error\sfrom\sserver\s\(([a-zA-Z]*)\):*/); - return matches && matches[1].toLocaleLowerCase() !== 'forbidden' ? false : true; - }); + new CommandText('oc', 'api-versions'), { timeout: 5000 }) + .then((response) => !response || response.trim().length === 0) // Active user is set - no need to login + .catch((error) => true); }) .catch((error) => true); // Can't get server - require to login } diff --git a/test/integration/ocWrapper.test.ts b/test/integration/ocWrapper.test.ts index 69c3424d1..5366b1db6 100644 --- a/test/integration/ocWrapper.test.ts +++ b/test/integration/ocWrapper.test.ts @@ -102,6 +102,15 @@ suite('./oc/ocWrapper.ts', function () { const project3 = 'my-test-project-3'; await Oc.Instance.createProject(project3); await Oc.Instance.deleteProject(project3); + + // Because 'my-test-project-3' namepace is still stays configured in Kube Config, + // it's been returned by `getProjects` in order to allow working with clusters that + // have a restriction on listing projects/namespaces. + // (see: https://github.com/redhat-developer/vscode-openshift-tools/issues/3999) + // So we need to set any other project as active before aqcuiring projects from the cluster, + // in order to make sure that required `my-test-projet-2` is deleted on the cluster: + await Oc.Instance.setProject('default'); + const projects = await Oc.Instance.getProjects(); const projectNames = projects.map((project) => project.name); expect(projectNames).to.not.contain(project3);