diff --git a/src/oc/devfileUtils.ts b/src/oc/devfileUtils.ts new file mode 100644 index 000000000..df1d69b92 --- /dev/null +++ b/src/oc/devfileUtils.ts @@ -0,0 +1,47 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as yaml from 'yaml'; + +export type DevfileInfo = { + path: string; + name: string; +}; + +export async function parseDevfile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = yaml.parse(content); + + if (parsed && typeof parsed === 'object' && parsed.schemaVersion && parsed.metadata?.name) { + return { + path: filePath, + name: parsed.metadata.name, + }; + } + } catch { + // ignore invalid YAML + } + + return undefined; +} + +export async function findDevfiles(componentDir: string): Promise { + const files = await fs.readdir(componentDir); + + const results = await Promise.all( + files + .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')) + .map((f) => parseDevfile(path.join(componentDir, f))), + ); + + return results.filter(Boolean) as DevfileInfo[]; +} + +export async function getComponentName(componentDir: string): Promise { + const devfiles = await findDevfiles(componentDir); + return devfiles[0]?.name; +} diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index 91ecec161..ae372e21b 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -14,6 +14,8 @@ import { CliExitData } from '../util/childProcessUtil'; import { isOpenShiftCluster, KubeConfigInfo, loadKubeConfig, serializeKubeConfig } from '../util/kubeUtils'; import { Project } from './project'; import { ClusterType, KubernetesConsole } from './types'; +import { findDevfiles, getComponentName } from './devfileUtils'; +import path from 'path'; /** * A wrapper around the `oc` CLI tool. @@ -895,4 +897,238 @@ export class Oc { undefined, true, config); return result.stdout; } + + async devWorkspaceExists(componentName: string): Promise { + try { + const result = await CliChannel.getInstance().executeTool( + new CommandText('oc', 'get', [ + new CommandOption('devworkspace'), + new CommandOption(componentName), + new CommandOption('-o'), + new CommandOption('name'), + ]), + ); + + return result?.stdout?.includes('devworkspace'); + } catch { + return false; + } + } + + async deleteOdoFiles(componentDir: string, componentName?: string): Promise { + const devfiles = await findDevfiles(componentDir); + + for (const devfile of devfiles) { + if (!componentName || devfile.name === componentName) { + await fs.rm(devfile.path, { force: true }); + } + } + + // Delete .odo directory + await fs.rm(path.join(componentDir, '.odo'), { + recursive: true, + force: true, + }); + } + + async findDevWorkspaceByLabel(componentName: string): Promise { + try { + const result = await CliChannel.getInstance().executeTool( + new CommandText('oc', 'get', [ + new CommandOption('devworkspace'), + new CommandOption('-l'), + new CommandOption(`app.kubernetes.io/component=${componentName}`), + new CommandOption('-o'), + new CommandOption('jsonpath={.items[0].metadata.name}'), + ]), + ); + const name = result?.stdout?.trim(); + return name || null; + } + catch { + return null; + } + + } + /** + * Deletes all the odo configuration files associated with the component (`.odo`, `devfile.yaml`) located at the given path. + * + * @param componentPath the path to the component + */ + public async deleteComponentConfiguration(componentPath: string): Promise { + const componentName = await getComponentName(componentPath); + + if (!componentName) { + throw new Error('Component name is missing. Cannot delete resources safely.'); + } + + const cli = CliChannel.getInstance(); + + /** + * Try to delete the DevWorkspace resource with the component label - + * this should trigger the cleanup of all associated resources by the controller and is the safest way to delete a component. + * If this fails (e.g. due to RBAC issues), we will try to delete resources by label in the next steps + */ + try { + await cli.executeTool( + new CommandText('oc', 'delete', [ + new CommandOption('devworkspace'), + new CommandOption(componentName), + new CommandOption('--ignore-not-found'), + ]), + ); + } catch { + // Ignore RBAC / not found + } + + /** + * Get all pods with the component label to find dynamic labels (like devworkspace_id) that we can use for safe deletion + */ + let podJson: any = {}; + try { + const podResult = await cli.executeTool( + new CommandText('oc', 'get', [ + new CommandOption('pod'), + new CommandOption('-l'), + new CommandOption(`app.kubernetes.io/instance=${componentName}`), + new CommandOption('-o'), + new CommandOption('json'), + ]), + ); + + podJson = JSON.parse(podResult.stdout || '{}'); + } catch { + podJson = {}; + } + + const items = podJson.items || []; + + /** + * Collect unique devworkspace IDs and instance labels from the pods to build label selectors for deletion. + */ + const devworkspaceIds = new Set(); + const instanceLabels = new Set(); + + for (const item of items) { + const labels = item?.metadata?.labels || {}; + + if (labels['controller.devfile.io/devworkspace_id']) { + devworkspaceIds.add(labels['controller.devfile.io/devworkspace_id']); + } + + if (labels['app.kubernetes.io/instance']) { + instanceLabels.add(labels['app.kubernetes.io/instance']); + } + } + + instanceLabels.add(componentName); + + /** + * Build label selectors for deletion based on collected labels. We will use these selectors to delete resources in the next steps, + * ensuring we only target resources associated with our component. + */ + const selectors: string[] = []; + + instanceLabels.forEach((val) => { + selectors.push(`app.kubernetes.io/instance=${val}`); + selectors.push(`component=${val}`); + selectors.push(`app=${val}`); + }); + + /** + * Delete all resources associated with the component using the built label selectors. + */ + try { + for (const selector of selectors) { + await cli.executeTool( + new CommandText('oc', 'delete', [ + new CommandOption('all'), + new CommandOption('-l'), + new CommandOption(selector), + new CommandOption('--ignore-not-found'), + ]), + ); + } + } catch { + // Ignore errors + } + + /** + * To ensure we cover resources that might not have the common labels but are still associated with the component + * (like ConfigMaps, Secrets, PVCs, Routes, etc.), we will also attempt to delete these resources using the same label selectors. + * This is a safety net to catch any resources that might have been missed in the previous step. + */ + const extraResources = [ + 'configmap', + 'secret', + 'pvc', + 'route', // OpenShift + 'ingress', + 'serviceaccount', + 'role', + 'rolebinding', + ]; + + try { + for (const resource of extraResources) { + await cli.executeTool( + new CommandText('oc', 'delete', [ + new CommandOption('-l'), + new CommandOption(resource), + new CommandOption('--ignore-not-found'), + ]), + ); + } + } catch { + // Ignore errors + } + + /** + * Delete all resources associated with each DevWorkspace ID. + */ + try { + for (const dwId of devworkspaceIds) { + const dwSelector = `controller.devfile.io/devworkspace_id=${dwId}`; + + await cli.executeTool( + new CommandText('oc', 'delete', [ + new CommandOption('all'), + new CommandOption('-l'), + new CommandOption(dwSelector), + new CommandOption('--ignore-not-found'), + ]), + ); + + await cli.executeTool( + new CommandText('oc', 'delete', [ + new CommandOption('route'), + new CommandOption('-l'), + new CommandOption(dwSelector), + new CommandOption('--ignore-not-found'), + ]), + ); + } + } catch { + // Ignore errors + } + + /** + * As a final safety measure, we will also attempt to delete any remaining resources that have the component instance label, + * even if they don't have the other common labels. + */ + try { + await cli.executeTool( + new CommandText('oc', 'delete', [ + new CommandOption('all'), + new CommandOption('--selector'), + new CommandOption(`app.kubernetes.io/instance=${componentName}`), + new CommandOption('--ignore-not-found'), + ]), + ); + } catch { + // ignore + } + + await this.deleteOdoFiles(componentPath, componentName); + } } diff --git a/src/odo/odoWrapper.ts b/src/odo/odoWrapper.ts index 452fc046c..1b001daa9 100644 --- a/src/odo/odoWrapper.ts +++ b/src/odo/odoWrapper.ts @@ -4,7 +4,7 @@ *-----------------------------------------------------------------------------------------------*/ import { Uri, WorkspaceFolder, workspace } from 'vscode'; -import { CommandOption, CommandText } from '../base/command'; +import { CommandText } from '../base/command'; import * as cliInstance from '../cli'; import { ToolsConfig } from '../tools'; import { ChildProcessUtil, CliExitData } from '../util/childProcessUtil'; @@ -80,7 +80,7 @@ export class Odo { location: Uri, starter: string = undefined, useExistingDevfile = false, - customDevfilePath = '' + customDevfilePath = '', ): Promise { await this.execute( Command.createLocalComponent( @@ -91,7 +91,7 @@ export class Odo { undefined, starter, useExistingDevfile, - customDevfilePath + customDevfilePath, ), location.fsPath, ); @@ -134,7 +134,7 @@ export class Odo { portNumber, undefined, false, - '' + '', ), location.fsPath, ); @@ -173,18 +173,4 @@ export class Odo { ); } - /** - * Deletes all the odo configuration files associated with the component (`.odo`, `devfile.yaml`) located at the given path. - * - * @param componentPath the path to the component - */ - public async deleteComponentConfiguration(componentPath: string): Promise { - await this.execute( - new CommandText('odo', 'delete component', [ - new CommandOption('--files'), - new CommandOption('-f'), - ]), - componentPath, - ); - } } diff --git a/src/openshift/component.ts b/src/openshift/component.ts index a19027c98..07dd587c8 100644 --- a/src/openshift/component.ts +++ b/src/openshift/component.ts @@ -568,7 +568,7 @@ export class Component extends OpenShiftItem { const CANCEL = 'Cancel'; const response = await window.showWarningMessage(`Are you sure you want to delete the configuration for the component ${context.contextPath}?\nOpenShift Toolkit will no longer recognize the project as a component.`, DELETE_CONFIGURATION, CANCEL); if (response === DELETE_CONFIGURATION) { - await Odo.Instance.deleteComponentConfiguration(context.contextPath); + await Oc.Instance.deleteComponentConfiguration(context.contextPath); void commands.executeCommand('openshift.componentsView.refresh'); } } diff --git a/test/integration/odoWrapper.test.ts b/test/integration/odoWrapper.test.ts index 2df11662c..a37265bc0 100644 --- a/test/integration/odoWrapper.test.ts +++ b/test/integration/odoWrapper.test.ts @@ -168,7 +168,7 @@ suite('./odo/odoWrapper.ts', function () { }); test('deleteComponentConfiguration()', async function() { - await Odo.Instance.deleteComponentConfiguration(tmpFolder); + await Oc.Instance.deleteComponentConfiguration(tmpFolder); try { await fs.access(path.join(tmpFolder, 'devfile.yaml')); fail('devfile.yaml should have been deleted') diff --git a/test/unit/openshift/component.test.ts b/test/unit/openshift/component.test.ts index 9f34b7ef3..9d6603158 100644 --- a/test/unit/openshift/component.test.ts +++ b/test/unit/openshift/component.test.ts @@ -25,14 +25,14 @@ import { Util } from '../../../src/util/async'; import { Util as fsp } from '../../../src/util/utils'; import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal'; import { comp1Folder } from '../../fixtures'; - +import { CliChannel } from '../../../src/cli'; const { expect } = chai; chai.use(sinonChai); suite('OpenShift/Component', function () { let sandbox: sinon.SinonSandbox; - let termStub: sinon.SinonStub; let execStub: sinon.SinonStub; + let termStub: sinon.SinonStub; let execStub: sinon.SinonStub; let ocExecStub: sinon.SinonStub; const fixtureFolder = path.join(__dirname, '..', '..', '..', 'test', 'fixtures').normalize(); const comp1Uri = vscode.Uri.file(path.join(fixtureFolder, 'components', 'comp1')); const comp2Uri = vscode.Uri.file(path.join(fixtureFolder, 'components', 'comp2')); @@ -134,6 +134,7 @@ suite('OpenShift/Component', function () { termStub = sandbox.stub(OpenShiftTerminalManager.prototype, 'executeInTerminal'); execStub = sandbox.stub(Odo.prototype, 'execute').resolves({ stdout: '', stderr: undefined, error: undefined }); + ocExecStub = sandbox.stub(CliChannel.prototype, 'executeTool').resolves({ stdout: '', stderr: '', error: undefined }); sandbox.stub(Oc.prototype, 'getProjects').resolves([projectItem]); sandbox.stub(Odo.prototype, 'describeComponent').resolves(componentItem1.component); sandbox.stub(OdoWorkspace.prototype, 'getComponents').resolves([componentItem1]); @@ -179,12 +180,20 @@ suite('OpenShift/Component', function () { showWarningMessageStub.resolves('Delete Configuration'); await Component.deleteConfigurationFiles({ component: { - // these fields aren't used + name: 'comp1', }, contextPath: wsFolder1.uri.fsPath - } as ComponentWorkspaceFolder); - expect(execStub.called).is.true; - expect(execStub.lastCall.args[0].toString().endsWith('odo component delete -f --force')); + } as unknown as ComponentWorkspaceFolder); + + expect(ocExecStub.called).to.be.true; + + const commands = ocExecStub.getCalls().map(call => + call.args[0].toString() + ); + + expect(commands.some(cmd => + cmd.includes('oc') && cmd.includes('delete') + )).to.be.true; }); test('cancel delete', async function () { @@ -235,7 +244,7 @@ suite('OpenShift/Component', function () { await Component.deleteSourceFolder({ component: { // these fields aren't used - }, + }, contextPath: wsFolder1.uri.fsPath } as ComponentWorkspaceFolder); expect(rmStub).to.be.called;