From e9dfbd7650e61b1baf6fd0ceabeef735349cecb4 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Wed, 12 Nov 2025 18:34:50 +0100 Subject: [PATCH] Replacement for 'odo describe component' Fixes: #5747 Signed-off-by: Victor Rubezhny Assisted-by: OpenAI ChatGPT --- src/odo/command.ts | 8 - src/odo/componentTypeDescription.ts | 33 +- src/odo/odoWrapper.ts | 14 +- src/odo/util/describe.ts | 881 ++++++++++++++++++ src/openshift/component.ts | 29 +- .../openshift-terminal/openShiftTerminal.ts | 52 +- test/integration/command.test.ts | 17 +- test/integration/odoWrapper.test.ts | 1 + test/ui/suite/componentContextMenu.ts | 30 +- test/ui/suite/createComponent.ts | 9 +- test/unit/index.ts | 8 +- test/unit/openshift/component.test.ts | 27 +- tsconfig.json | 2 +- 13 files changed, 1042 insertions(+), 69 deletions(-) create mode 100644 src/odo/util/describe.ts diff --git a/src/odo/command.ts b/src/odo/command.ts index c406e157b..5761857a7 100644 --- a/src/odo/command.ts +++ b/src/odo/command.ts @@ -38,14 +38,6 @@ export class Command { return new CommandText('odo', 'version'); } - static describeComponent(): CommandText { - return new CommandText('odo', 'describe component'); - } - - static describeComponentJson(): CommandText { - return Command.describeComponent().addOption(new CommandOption('-o', 'json', false)); - } - @verbose static createLocalComponent( devfileType = '', // will use empty string in case of undefined devfileType passed in diff --git a/src/odo/componentTypeDescription.ts b/src/odo/componentTypeDescription.ts index ff44648ca..65d727fdf 100644 --- a/src/odo/componentTypeDescription.ts +++ b/src/odo/componentTypeDescription.ts @@ -8,16 +8,21 @@ export interface Ctx { } export interface ForwardedPort { - containerName: string, - localAddress: string, - localPort: number, - containerPort: number, + containerName: string; + localAddress: string; + localPort: number; + containerPort: number; + name?: string; + isDebug?: boolean; + exposure?: string; + platform?: string; } export interface ComponentDescription { devfilePath: string; devfileData: { devfile: Data; + commands: any[], supportedOdoFeatures: { debug: boolean; deploy: boolean; @@ -26,7 +31,27 @@ export interface ComponentDescription { } devForwardedPorts: ForwardedPort[], runningIn: string[]; + runningOn: string[]; + devControlPlane?: { + platform: string + localPort?: number + apiServerPath?: string + webInterfacePath?: string + }[]; managedBy: string; + warnings?: string[]; +} + +export type DevControlPlaneInfo = NonNullable; + +export interface CommandInfo { + name: string + type: string + group: string + isDefault: boolean + commandLine: string + component: string + componentType: string } export interface ComponentItem { diff --git a/src/odo/odoWrapper.ts b/src/odo/odoWrapper.ts index 452fc046c..398c8da5b 100644 --- a/src/odo/odoWrapper.ts +++ b/src/odo/odoWrapper.ts @@ -6,6 +6,7 @@ import { Uri, WorkspaceFolder, workspace } from 'vscode'; import { CommandOption, CommandText } from '../base/command'; import * as cliInstance from '../cli'; +import { getComponentDescription } from '../odo/util/describe'; import { ToolsConfig } from '../tools'; import { ChildProcessUtil, CliExitData } from '../util/childProcessUtil'; import { VsCommandError } from '../vscommand'; @@ -30,20 +31,13 @@ export class Odo { } public async describeComponent( - contextPath: string, - experimental = false, + contextPath: string ): Promise { - const expEnv = experimental ? { ODO_EXPERIMENTAL_MODE: 'true' } : {}; try { - const describeCmdResult: CliExitData = await this.execute( - Command.describeComponentJson(), - contextPath, - false, - expEnv, - ); - return JSON.parse(describeCmdResult.stdout) as ComponentDescription; + return await getComponentDescription(contextPath, {}) } catch { // ignore and return undefined + return undefined; } } diff --git a/src/odo/util/describe.ts b/src/odo/util/describe.ts new file mode 100644 index 000000000..464bb065e --- /dev/null +++ b/src/odo/util/describe.ts @@ -0,0 +1,881 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { AppsV1Api, NetworkingV1Api } from '@kubernetes/client-node'; +import * as fs from 'fs/promises'; +import * as yaml from 'js-yaml'; +import path from 'path'; +import { DevfileInfo } from '../../devfile-registry/devfileInfo'; +import { DevfileRegistry } from '../../devfile-registry/devfileRegistryWrapper'; +import { KubeConfigInfo } from '../../util/kubeUtils'; +import { + CommandInfo, + ComponentDescription, + ComponentItem, + Container, + Data, + DevControlPlaneInfo, + ForwardedPort, + StarterProject +} from '../componentTypeDescription'; + +/* =========================================================== + * Cluster helper + * =========================================================== */ + +async function checkClusterInfo(namespace: string, componentName: string, timeoutMs = 3000): Promise<{ + runningIn: string[]; + runningOn: string[]; + managedBy?: string; + warnings?: string[]; +}> { + const k8sConfigInfo = new KubeConfigInfo(); + const kc = k8sConfigInfo.getEffectiveKubeConfig(); + + const k8sApi: NetworkingV1Api = kc.makeApiClient(NetworkingV1Api); + const appsApi: AppsV1Api = kc.makeApiClient(AppsV1Api); + + const currentContext = k8sConfigInfo.findContext(kc.currentContext); + const effectiveNamespace = + namespace || currentContext.namespace || 'default'; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const [ing, dep] = await Promise.all([ + k8sApi.listNamespacedIngress( + { + namespace: effectiveNamespace, + labelSelector: `app.kubernetes.io/instance=${componentName}` + }, + undefined, + undefined, + undefined, + { signal: controller.signal as any } + ), + + appsApi.listNamespacedDeployment( + { + namespace: effectiveNamespace, + labelSelector: `app.kubernetes.io/instance=${componentName}` + }, + undefined, + undefined, + undefined, + { signal: controller.signal as any } + ) + ]); + + const runningIn = (ing.items ?? []) + .map(i => i.metadata?.name) + .filter(Boolean); + + const runningOn = runningIn.length > 0 ? ['cluster: Deploy'] : []; + + const managedBy = + dep.items?.[0]?.metadata?.labels?.[ + 'app.kubernetes.io/managed-by' + ]; + + return { + runningIn, + runningOn, + managedBy + }; + + } catch (err: any) { + const isAbort = + err?.name === 'AbortError' || + err?.message?.toLowerCase?.().includes('abort'); + + return { + runningIn: [], + runningOn: [], + managedBy: undefined, + warnings: [ + isAbort + ? `Cluster request timed out after ${timeoutMs}ms` + : extractK8sErrorMessage(err) + ] + }; + + } finally { + clearTimeout(timeout); + } +} + +type DevState = { + pid: number; + platform: 'cluster' | 'podman' | 'docker' | string; + forwardedPorts?: { + containerName: string; + portName?: string; + isDebug?: boolean; + localAddress: string; + localPort: number; + containerPort: number; + exposure?: string; + }[]; + apiServerPort?: number; +}; + +async function readDevState(devfilePath: string): Promise< DevState | null> { + try { + const file = path.join(path.dirname(devfilePath), '.odo', 'devstate.json'); + return JSON.parse(await fs.readFile(file, 'utf-8')); + } catch { + return null; + } +} + +function extractK8sErrorMessage(err: unknown): string { + if (!err) return 'Unknown error'; + + const text = typeof err === 'string' ? err : err instanceof Error ? err.message : String(err); + + const match = text.match(/Message:\s*([^\n]+)/i); + if (match) { + return match[1].trim(); + } + + return text.split('\n')[0].trim(); +} + +/* =========================================================== + * Exported functions + * =========================================================== */ + +export async function getComponentDescription( + devfilePath: string, + opts?: { namespace?: string; componentName?: string; timeoutMs?: number; debug?: boolean } +): Promise { + + const resolver = new DevfileResolver(); + const resolvedDevfilePath = await DevfileResolver.resolveDevfilePath(devfilePath); + if (!resolvedDevfilePath) { + return undefined; // NOT a component → just skip + } + + const devfile = await resolver.loadDevfile(resolvedDevfilePath); + const mergedDevfile = await resolver.resolve(devfile); + + + const normalizedCommands = normalizeCommands(mergedDevfile); + const supportedOdoFeatures = detectSupportedFeatures(normalizedCommands); + + let runningIn: string[] = []; + let runningOn: string[] = []; + let managedBy = undefined; + let warnings: string[] = []; + let devForwardedPorts: ForwardedPort[] = []; + const devControlPlane: ComponentDescription['devControlPlane'] = []; + + const devstate = await readDevState(resolvedDevfilePath); + if (devstate) { + const runtime = devstate.platform ?? 'unknown'; + + runningIn.push('Dev'); + runningOn.push(`${runtime}: Dev`); + + devForwardedPorts = extractForwardedPortsFromDevState(devstate); + + if (devstate.apiServerPort) { + if (runtime !== 'cluster') { + devControlPlane.push( { + platform: 'cluster', + localPort: devstate.apiServerPort, + apiServerPath: '/api/v1/', + webInterfacePath: '/' + }); + } + devControlPlane.push({ + platform: runtime, + localPort: devstate.apiServerPort, + apiServerPath: '/api/v1/', + webInterfacePath: '/' + }); + } + } else { + const clusterInfo = await checkClusterInfo(opts.namespace, opts.componentName, opts.timeoutMs ?? 3000) + runningIn = clusterInfo.runningIn + runningOn = clusterInfo.runningOn + managedBy = clusterInfo.managedBy && clusterInfo.managedBy.length > 0 ? clusterInfo.managedBy : devfile ? 'odo' : 'Unknown'; + warnings = clusterInfo.warnings + } + + return { + devfilePath: resolvedDevfilePath, + devfileData: { + devfile: mergedDevfile, + commands: normalizedCommands, + supportedOdoFeatures, + }, + devForwardedPorts, + runningIn, + runningOn, + devControlPlane, + managedBy, + warnings + }; +} + +export async function describeComponentYAML( + devfilePath: string, + options?: { + useBold?: boolean + namespace?: string + componentName?: string + timeoutMs?: number + debug?: boolean + } +): Promise<{ componentInfo: ComponentDescription; description: string }> { + const { useBold = true } = options ?? {} + const bold = useBold ? (t: string) => `\x1b[1m${t}\x1b[0m` : (t: string) => t + + const componentInfo = await getComponentDescription(devfilePath, options) + const devfile = componentInfo.devfileData.devfile; + const supportedOdoFeatures = componentInfo.devfileData.supportedOdoFeatures; + const devForwardedPorts = componentInfo.devForwardedPorts + + const lines: string[] = [] + + appendWarnings(lines, componentInfo.warnings ?? [], bold) + appendMetadata(lines, devfile.metadata, devfile.schemaVersion, bold) + appendRunningIn(lines, componentInfo.runningIn, bold) + appendRunningOn(lines, componentInfo.runningOn, bold) + appendDevControlPlane(lines, componentInfo.devControlPlane, bold) + appendForwardedPorts(lines, devForwardedPorts, bold) + appendSupportedFeatures(lines, supportedOdoFeatures, bold) + appendCommands(lines, flattenCommands(devfile), bold) + appendContainerComponents(lines, devfile.components, bold) + appendKubernetesComponents(lines, devfile.components, bold) + appendVolumes(lines, devfile.components, bold) + if (options?.debug) { + appendStarterProjects(lines, devfile.starterProjects, bold) + } + appendEvents(lines, devfile.events, bold) + appendManagedBy(lines, componentInfo.managedBy, bold) + + return { componentInfo, description: lines.join('\n') } +} + +export async function describeComponentJSON(componentInfo: ComponentDescription) { + return JSON.stringify({ + devfilePath: componentInfo.devfilePath, + devfileData: { + devfile: componentInfo.devfileData.devfile, + commands: flattenCommands(componentInfo.devfileData.devfile), + supportedOdoFeatures: componentInfo.devfileData.supportedOdoFeatures + }, + devControlPlane: componentInfo.devControlPlane, + devForwardedPorts: componentInfo.devForwardedPorts, + runningIn: + componentInfo.runningIn?.length + ? componentInfo.runningIn + : null, + + managedBy: componentInfo.managedBy + }, null, 2) +} + +/* =========================================================== + * Private helpers + * =========================================================== */ + +class DevfileResolver { + + private static parentCache = new Map(); + + static invalidateCache() { + this.parentCache.clear(); + } + + async resolve(devfile: any): Promise { + const chain = await this.resolveParentChain(devfile); + const mergedDevfile = this.mergeChain(chain); + + return this.normalizeResolvedDevfile(mergedDevfile); + } + + private normalizeResolvedDevfile(devfile: any): any { + const result = { ...devfile }; + if (result?.parent) { + delete result.parent; + } + return result; + } + + async loadDevfile(path: string): Promise { + const raw = await fs.readFile(path, 'utf-8'); + return path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw); + } + + static async resolveDevfilePath(devfilePath: string): Promise { + try { + const stats = await fs.stat(devfilePath); + + if (stats.isFile()) { + return devfilePath; + } + + if (stats.isDirectory()) { + const candidates = ['devfile.yaml', 'devfile.yml', 'devfile.json']; + + for (const file of candidates) { + const fullPath = path.join(devfilePath, file); + try { + await fs.access(fullPath); + return fullPath; + } catch { + // ignore + } + } + } + } catch { + // ignore + } + return undefined; + } + + private async resolveParentChain(devfile: any): Promise { + const chain: any[] = []; + const visited = new Set(); + + let current = devfile; + + while (current?.parent) { + const key = this.getParentKey(current.parent); + + if (visited.has(key)) { + throw new Error(` ✗ Circular parent reference detected: ${key}`); + } + visited.add(key); + + const parent = await this.fetchParentDevfile(current.parent); + + chain.unshift(parent); + current = parent; + } + + chain.push(devfile); + + return chain; + } + + private normalizeParent(parent: any) { + return { + registry: (parent.registryUrl || 'https://registry.devfile.io').replace(/\/$/, ''), + id: parent.id, + version: parent.version || 'latest', + }; + } + + private getParentKey(parent: any): string { + const p = this.normalizeParent(parent); + return `${p.registry}|${p.id}|${p.version}`; + } + + private normalizeVersion(v?: string): string | undefined { + if (!v) return undefined; + + const parts = v.split('.'); + + // normalize 2.2 → 2.2.0 + if (parts.length === 2) { + return `${parts[0]}.${parts[1]}.0`; + } + + return v; + } + + private resolveVersionEntry(stack: DevfileInfo, requested?: string) { + const normalizedRequested = this.normalizeVersion(requested); + + return ( + stack.versions.find(v => + this.normalizeVersion(v.version) === normalizedRequested + ) + ?? stack.versions.find(v => v.version === requested) + ?? stack.versions.find(v => v.default) + ?? stack.versions[0] + ); + } + + private async fetchFromRegistry(parent: any): Promise { + const p = this.normalizeParent(parent); + + const registry = DevfileRegistry.Instance; + const index = await registry.getDevfileInfoList(p.registry); + const stack = index.find((s: DevfileInfo) => s.name === p.id); + if (!stack) { + throw new Error(`Stack not found: ${p.id}`); + } + + const versionEntry = this.resolveVersionEntry(stack, p.version); + if (!versionEntry) { + throw new Error(`Version not found: ${p.id}@${p.version}`); + } + + return registry.getRegistryDevfile( + p.registry, + p.id, + versionEntry.version + ); + } + + private async fetchParentDevfile(parent: any): Promise { + const key = this.getParentKey(parent); + + const cached = DevfileResolver.parentCache.get(key); + if (cached) { + return cached; + } + + try { + const devfile = await this.fetchFromRegistry(parent); + + DevfileResolver.parentCache.set(key, devfile); + + return devfile; + } catch (err: any) { + throw new Error(` ✗ Failed to fetch parent devfile ${key}: ${err?.message || err}`); + } + } + + private mergeChain(chain: any[]): any { + return chain.reduce((acc, curr) => this.mergeDevfiles(acc, curr)); + } + + private mergeDevfiles(parent: any, child: any): any { + return { + ...parent, + ...child, + metadata: this.deepMerge(parent.metadata, child.metadata), + components: this.mergeByName(parent.components, child.components), + commands: this.mergeById(parent.commands, child.commands), + attributes: this.deepMerge(parent.attributes, child.attributes), + }; + } + + private mergeByName(parent: any[] = [], child: any[] = []) { + const map = new Map(); + + for (const item of parent) { + map.set(item.name, item); + } + + for (const item of child) { + if (map.has(item.name)) { + map.set(item.name, this.deepMerge(map.get(item.name), item)); + } else { + map.set(item.name, item); + } + } + + return Array.from(map.values()); + } + + private mergeById(parent: any[] = [], child: any[] = []) { + const map = new Map(); + + for (const item of parent) { + map.set(item.id, item); + } + + for (const item of child) { + if (map.has(item.id)) { + map.set(item.id, this.deepMerge(map.get(item.id), item)); + } else { + map.set(item.id, item); + } + } + + return Array.from(map.values()); + } + + private deepMerge(target: any, source: any): any { + if (!target) return source; + if (!source) return target; + + const result = { ...target }; + + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const tgtVal = target[key]; + + if ( + typeof srcVal === 'object' && + srcVal !== null && + !Array.isArray(srcVal) + ) { + result[key] = this.deepMerge(tgtVal, srcVal); + } else { + result[key] = srcVal; + } + } + + return result; + } +} + +function flattenCommands(devfile): CommandInfo[] { + return (devfile.commands ?? []).map(cmd => { + + if (cmd.exec) { + return { + name: cmd.id, + type: 'exec', + group: cmd.exec.group?.kind, + commandLine: cmd.exec.commandLine, + component: cmd.exec.component, + componentType: 'container' + }; + } + + if (cmd.apply) { + return { + name: cmd.id, + type: 'apply', + component: cmd.apply.component, + componentType: cmd.apply.componentType ?? inferComponentType(devfile, cmd.apply.component), + imageName: inferImageName(devfile, cmd.apply.component) + }; + } + + if (cmd.composite) { + return { + name: cmd.id, + type: 'composite', + group: cmd.composite.group?.kind, + commands: cmd.composite.commands + }; + } + + return { + name: cmd.id, + type: 'unknown' + }; + }); +} + +function inferComponentType(devfile, componentName: string): string { + const comp = (devfile.components ?? []).find(c => c.name === componentName); + + if (!comp) return 'unknown'; + if (comp.container) return 'container'; + if (comp.kubernetes) return 'kubernetes'; + if (comp.image) return 'image'; + + return 'unknown'; +} + +function inferImageName(devfile, componentName: string): string | undefined { + const comp = (devfile.components ?? []).find(c => c.name === componentName); + return comp?.image?.imageName; +} + +function appendWarnings(lines: string[], warnings: string[], bold: (text: string) => string) { + if (!warnings || warnings.length === 0) { + return '' + } + + const BORDER = '='.repeat(49) + + lines.push(BORDER) + for (const w of warnings) { + lines.push(`⚠ failed to get ingresses/routes: ${w}`) + } + lines.push(BORDER) + + return lines.join('\n') +} + +function appendMetadata(lines: string[], meta: Data['metadata'], schemaVersion: string | undefined, bold: (text: string) => string) { + lines.push(`${bold('Name:')} ${meta.name}`); + if (meta.displayName) lines.push(`${bold('Display Name:')} ${meta.displayName}`); + if (meta.projectType) lines.push(`${bold('Project Type:')} ${meta.projectType}`); + if (meta.language) lines.push(`${bold('Language:')} ${meta.language}`); + if (meta.version) lines.push(`${bold('Version:')} ${meta.version}`); + if (meta.description) lines.push(`${bold('Description:')} ${meta.description}`); + if (meta.tags?.length) lines.push(`${bold('Tags:')} ${meta.tags.join(', ')}`); + if (schemaVersion) lines.push(`${bold('Schema Version:')} ${schemaVersion}`); + + lines.push('') +} + +function appendRunningIn(lines: string[], runningIn: string[], bold: (text: string) => string) { + if (!runningIn.length) { + lines.push('Running in: None') + } else { + lines.push(`Running in: ${runningIn.join(', ')}`) + } + + lines.push('') +} + +function appendRunningOn(lines: string[], runningOn: string[], bold: (t: string) => string) { + if (!runningOn.length) return + + lines.push(`${bold('Running on')}:`) + + runningOn.forEach(r => { + lines.push(` • ${r}`) + }) + + lines.push(''); +} + +function appendDevControlPlane(lines: string[], devControlPlane: DevControlPlaneInfo | undefined, bold: (t: string) => string) { + if (!devControlPlane?.length) return; + + lines.push(`${bold('Dev Control Plane')}:`); + + devControlPlane.forEach(cp => { + lines.push(bold(` • ${cp.platform}`)); + + if (cp.localPort) { + if (cp.apiServerPath) { + lines.push( + ` API: http://localhost:${cp.localPort}${cp.apiServerPath}` + ); + } + + if (cp.webInterfacePath) { + lines.push( + ` Web UI: http://localhost:${cp.localPort}${cp.webInterfacePath}` + ); + } + } + }); + + lines.push(''); +} + +function appendManagedBy(lines: string[], managedBy: string, bold: (text: string) => string) { + if (!managedBy) return + + lines.push(`${bold('Managed by:')} ${managedBy}`) + lines.push('') +} + +function appendSupportedFeatures(lines: string[], features: { dev: boolean; deploy: boolean; debug: boolean }, bold: (text: string) => string) { + lines.push(`${bold('Supported odo features:')}`); + lines.push(` • Dev: ${features.dev}`); + lines.push(` • Deploy: ${features.deploy}`); + lines.push(` • Debug: ${features.debug}`); + lines.push(''); +} + +function appendCommands(lines: string[], commands: any[], bold: (text: string) => string) { + if (!commands?.length) return; + + lines.push(`${bold('Commands:')}`); + + for (const cmd of commands) { + lines.push(` • ${cmd.name}`); + lines.push(` ${bold('Type:')} ${cmd.type}`); + + if (cmd.type === 'exec') { + lines.push(` ${bold('Group:')} ${cmd.group ?? 'N/A'}`); + lines.push(` ${bold('Command Line:')} "${cmd.commandLine}"`); + lines.push(` ${bold('Component:')} ${cmd.component}`); + lines.push(` ${bold('Component Type:')} ${cmd.componentType}`); + } else if (cmd.type === 'composite') { + if (cmd.group) { + lines.push(` ${bold('Group:')} ${cmd.group}`); + } + if (cmd.commands?.length) { + lines.push(` ${bold('Commands:')} ${cmd.commands.join(', ')}`); + } + } else if (cmd.type === 'apply') { + lines.push(` ${bold('Component:')} ${cmd.component}`); + lines.push(` ${bold('Component Type:')} ${cmd.componentType}`); + + if (cmd.imageName) { + lines.push(` ${bold('Image Name:')} ${cmd.imageName}`); + } + } + } + + lines.push(''); +} + +function appendContainerComponents(lines: string[], components: ComponentItem[], bold: (text: string) => string) { + const containers = components?.filter((c) => c.container); + if (!containers?.length) return; + + lines.push(`${bold('Container components:')}`); + for (const comp of containers) { + const cont = comp.container as Container; + lines.push(` • ${comp.name}`); + lines.push(` ${bold('Source Mapping:')} /projects`); + if (cont.endpoints?.length) { + lines.push(` ${bold('Endpoints:')}`); + for (const ep of cont.endpoints) { + lines.push(` - ${ep.name} (${ep.targetPort})`); + } + } + + lines.push(''); + } +} + +function appendKubernetesComponents(lines: string[], components: ComponentItem[], bold: (text: string) => string) { + const kube = components?.filter((c) => c.kubernetes); + if (!kube?.length) return; + + lines.push(`${bold('Kubernetes components:')}`); + + for (const comp of kube) { + lines.push(` • ${comp.name}`); + } + + lines.push(''); +} + +function appendVolumes(lines: string[], components: ComponentItem[], bold: (text: string) => string) { + const allVolumes: Data['components'][number]['container']['volumeMounts'] = []; + for (const c of components ?? []) { + if (c.container?.volumeMounts?.length) { + allVolumes.push(...c.container.volumeMounts); + } + } + if (!allVolumes.length) return; + + lines.push(`${bold('Volumes:')}`); + for (const v of allVolumes) { + lines.push(` • ${v.name} mounted at ${v.path}`); + } + + lines.push(''); +} + +function appendStarterProjects(lines: string[], starters?: StarterProject[], bold?: (text: string) => string) { + if (!starters?.length) return; + + lines.push(`${bold?.('Starter Projects:')}`); + + for (const sp of starters) { + lines.push(` • ${sp.name}`); + if (sp.description) lines.push(` ${bold?.('Description:')} ${sp.description}`); + if (sp.git) { + const remotes = Object.entries(sp.git.remotes ?? {}) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + lines.push(` ${bold?.('Git Remotes:')} ${remotes}`); + } + if (sp.zip) lines.push(` ${bold?.('Zip:')} ${sp.zip.location}`); + } + + lines.push(''); +} + +function appendEvents(lines: string[], events?: Data['events'], bold?: (text: string) => string) { + if (!events) return; + + const keys = Object.keys(events) as (keyof typeof events)[]; + if (!keys.length) return; + + lines.push(`${bold?.('Events:')}`); + + for (const key of keys) { + const val = events[key]; + if (Array.isArray(val)) lines.push(` • ${key}: ${val.join(', ')}`); + } + + lines.push(''); +} + +function appendForwardedPorts(lines: string[], ports: ForwardedPort[], bold: (text: string) => string) { + if (!ports?.length) return; + + lines.push(`${bold('Forwarded ports:')}`); + + for (const port of ports) { + lines.push(` • [${port.platform}] ${port.localAddress}:${port.localPort} -> ${port.containerName}:${port.containerPort}`) + if (port.name) { + lines.push(` Name: ${port.name}`); + } + if (port.exposure) { + lines.push(` Exposure: ${port.exposure}`); + } + if (port.isDebug) { + lines.push(' Debug: true'); + } + } + + lines.push(''); +} + +/* =========================================================== + * Data Extraction Utilities + * =========================================================== */ + +function normalizeCommands(devfile: any): any[] { + const commands = devfile.commands || []; + + return commands.map(cmd => { + if (cmd.exec) { + return { + name: cmd.id, + group: cmd.exec.group?.kind, + isDefault: cmd.exec.group?.isDefault, + type: 'exec' + }; + } + + if (cmd.apply) { + return { + name: cmd.id, + type: 'apply' + }; + } + + if (cmd.composite) { + return { + name: cmd.id, + group: cmd.composite.group?.kind, + isDefault: cmd.composite.group?.isDefault, + type: 'composite' + }; + } + + return { name: cmd.id }; + }); +} + +function detectSupportedFeatures(commands: any[]) { + return { + dev: commands.some(c => c.group === 'run'), + debug: commands.some(c => c.group === 'debug'), + deploy: commands.some(c => c.group === 'deploy' || c.type === 'apply'), + }; +} + +function extractForwardedPortsFromDevState(devState): ForwardedPort[] { + if (!devState?.forwardedPorts) return []; + + const runtime = devState.platform ?? 'unknown'; + const base = devState.forwardedPorts.map(p => ({ + containerName: p.containerName, + localAddress: p.localAddress, + localPort: p.localPort, + containerPort: p.containerPort, + name: p.portName, + isDebug: p.isDebug, + exposure: p.exposure + })); + + const forwardedPorts: ForwardedPort[] = []; + + if (runtime !== 'cluster') { + forwardedPorts.push(...base.map(p => ({ ...p, platform: 'cluster' }))); + } + + forwardedPorts.push(...base.map(p => ({...p, platform: runtime}))); + + return forwardedPorts; +} diff --git a/src/openshift/component.ts b/src/openshift/component.ts index a19027c98..112f6cd70 100644 --- a/src/openshift/component.ts +++ b/src/openshift/component.ts @@ -11,6 +11,7 @@ import { Oc } from '../oc/ocWrapper'; import { Command } from '../odo/command'; import { CommandProvider } from '../odo/componentTypeDescription'; import { Odo } from '../odo/odoWrapper'; +import { describeComponentYAML } from '../odo/util/describe'; import { ComponentWorkspaceFolder } from '../odo/workspace'; import { ChildProcessUtil, CliExitData } from '../util/childProcessUtil'; import { Progress } from '../util/progress'; @@ -102,7 +103,9 @@ export class Component extends OpenShiftItem { debugStatus: folder.component?.devfileData?.supportedOdoFeatures?.debug ? ComponentContextState.DEB : undefined, deployStatus: folder.component?.devfileData?.supportedOdoFeatures?.deploy ? ComponentContextState.DEP : undefined, }; - this.componentStates.set(folder.contextPath, state); + if (folder.component?.devfileData?.supportedOdoFeatures !== undefined) { + Component.componentStates.set(folder.contextPath, state); + } } return state; } @@ -322,7 +325,7 @@ export class Component extends OpenShiftItem { @vsCommand('openshift.component.openInBrowser') @clusterRequired() static async openInBrowser(component: ComponentWorkspaceFolder): Promise { - const componentDescription = await Odo.Instance.describeComponent(component.contextPath, !!Component.getComponentDevState(component).runOn); + const componentDescription = await Odo.Instance.describeComponent(component.contextPath); if (componentDescription.devForwardedPorts?.length === 1) { const fp = componentDescription.devForwardedPorts[0]; await commands.executeCommand('vscode.open', Uri.parse(`http://${fp.localAddress}:${fp.localPort}`)); @@ -352,11 +355,20 @@ export class Component extends OpenShiftItem { @vsCommand('openshift.component.describe', true) static async describe(componentFolder: ComponentWorkspaceFolder): Promise { - const command = Command.describeComponent(); - await OpenShiftTerminalManager.getInstance().executeInTerminal( - command, + const componentName = componentFolder.component.devfileData.devfile.metadata.name; + const devfilePath = componentFolder.component.devfilePath; + + const { description } = await describeComponentYAML(devfilePath, { + namespace: undefined, // Current Kube config context will be used + componentName, + useBold: true + }); + + await OpenShiftTerminalManager.getInstance().writeToTerminal( + description, componentFolder.contextPath, - `Describe '${componentFolder.component.devfileData.devfile.metadata.name}' Component`); + `Describe '${componentName}' Component` + ); } @vsCommand('openshift.component.openCreateComponent') @@ -482,12 +494,13 @@ export class Component extends OpenShiftItem { } static async startOdoAndConnectDebugger(component: ComponentWorkspaceFolder, config: DebugConfiguration): Promise { - const componentDescription = await Odo.Instance.describeComponent(component.contextPath, !!Component.getComponentDevState(component).runOn); + const componentDescription = await Odo.Instance.describeComponent(component.contextPath); if (componentDescription.devForwardedPorts?.length > 0) { // try to find debug port const debugPortsCandidates:number[] = []; componentDescription.devForwardedPorts.forEach((pf) => { - const devComponent = componentDescription.devfileData.devfile.components.find(item => item.name === pf.containerName); + const devfile = componentDescription?.devfileData?.devfile; + const devComponent = devfile?.components?.find(item => item.name === pf.containerName); if (devComponent?.container) { const candidatePort = devComponent.container.endpoints.find(endpoint => endpoint.targetPort === pf.containerPort); if (candidatePort.name.startsWith('debug')) { diff --git a/src/webview/openshift-terminal/openShiftTerminal.ts b/src/webview/openshift-terminal/openShiftTerminal.ts index 2ad8a4605..428035a8e 100644 --- a/src/webview/openshift-terminal/openShiftTerminal.ts +++ b/src/webview/openshift-terminal/openShiftTerminal.ts @@ -104,6 +104,8 @@ class OpenShiftTerminal { private _encoder = new TextEncoder(); + private _spawnPty: boolean; + /** * Creates a new OpenShiftTerminal * @@ -130,6 +132,7 @@ class OpenShiftTerminal { onExit?: () => void; onText?: (text: string) => void; }, + spawnPty = true ) { this._uuid = uuid; @@ -157,6 +160,8 @@ class OpenShiftTerminal { this._disposables.push(this._headlessTerm); this._disposables.push(this._termSerializer); + + this._spawnPty = spawnPty; } startPty() { @@ -301,6 +306,17 @@ class OpenShiftTerminal { public write(data: string) { if (this.isPtyLive) { this._pty.write(data); + } else if (!this._spawnPty) { + if (this._terminalRendering) { + this._sendTerminalData(data); + this._headlessTerm.write(data); + } else { + this._buffer = Uint8Array.from([ + ...this._buffer, + ...Uint8Array.from(this._encoder.encode(data)) + ]); + } + this._onTextListener(data); } else if (this._ptyExited) { this._sendExitMessage(); } @@ -363,7 +379,7 @@ class OpenShiftTerminal { */ public startRendering(): void { this._terminalRendering = true; - if (!this._pty) { + if (this._spawnPty && !this._pty) { this.startPty(); } } @@ -586,6 +602,40 @@ export class OpenShiftTerminalManager implements WebviewViewProvider { await OpenShiftTerminalManager.getInstance().createTerminal(command, name, cwd, merged); } + public async writeToTerminal(text: string, cwd = process.cwd(), name = 'OpenShift'): Promise { + await commands.executeCommand('openShiftTerminalView.focus'); + await this.webviewResolved; + + const uuid = randomUUID(); + + const term = new OpenShiftTerminal( + uuid, + (message: Message) => this.sendMessage(message), + '', // no executable + [], + { cwd, env: process.env, name }, + false, + undefined, + false // do NOT spawn PTY + ); + + this.openShiftTerminals.set(uuid, term); + + await this.sendMessage({ + kind: 'createTerminal', + data: { uuid, name } + }); + + const normalized = text.replace(/\r?\n/g, '\r\n'); + const CHUNK = 2000; + + for (let i = 0; i < normalized.length; i += CHUNK) { + term.write(normalized.slice(i, i + CHUNK)); + } + + term.write('\r\n\r\n--- Close this terminal tab when finished ---\r\n'); + } + /** * Run a command in the OpenShift Terminal view and return an api to interact with the running command. * diff --git a/test/integration/command.test.ts b/test/integration/command.test.ts index 31237d3e2..f8df57206 100644 --- a/test/integration/command.test.ts +++ b/test/integration/command.test.ts @@ -17,7 +17,6 @@ import { CommandText } from '../../src/base/command'; import { CliChannel } from '../../src/cli'; import { Oc } from '../../src/oc/ocWrapper'; import { Command } from '../../src/odo/command'; -import { ComponentDescription } from '../../src/odo/componentTypeDescription'; import { OdoPreference } from '../../src/odo/odoPreference'; import { Odo } from '../../src/odo/odoWrapper'; import { LoginUtil } from '../../src/util/loginUtil'; @@ -102,18 +101,6 @@ suite('odo commands integration', function () { await fs.access(path.join(componentLocation, 'devfile.yaml')); }); - test('describeComponent()', async function() { - const res = await ODO.execute(Command.describeComponent(), componentLocation); - expect(res.stdout).contains(componentName); - expect(res.stdout).contains('Go'); - }); - - test('describeComponentJson()', async function () { - const res = await ODO.execute(Command.describeComponentJson(), componentLocation); - expect(res.stdout).contains(componentName); - expect(res.stdout).contains(componentType); - }); - suite('deploying', function() { // FIXME: Deploy depends on pushing container images to a registry. // The default registry it tries to push to is docker. @@ -384,8 +371,8 @@ suite('odo commands integration', function () { await fixupDevFile(devfilePath); - const describeCmdResult = await ODO.execute(Command.describeComponentJson(), componentLocation); - const componentDescription = JSON.parse(describeCmdResult.stdout) as ComponentDescription; + const componentDescription = await Odo.Instance.describeComponent(componentLocation); + expect(componentDescription.devfileData.devfile.commands[0]?.id).exist; const commands = componentDescription.devfileData.devfile.commands diff --git a/test/integration/odoWrapper.test.ts b/test/integration/odoWrapper.test.ts index 2df11662c..182de85b6 100644 --- a/test/integration/odoWrapper.test.ts +++ b/test/integration/odoWrapper.test.ts @@ -135,6 +135,7 @@ suite('./odo/odoWrapper.ts', function () { const componentDescription1 = await Odo.Instance.describeComponent(tmpFolder1.fsPath); expect(componentDescription1).to.exist; expect(componentDescription1.managedBy).to.equal('odo'); + const componentDescription2 = await Odo.Instance.describeComponent(tmpFolder2.fsPath); expect(componentDescription2).to.exist; expect(componentDescription2.managedBy).to.equal('odo'); diff --git a/test/ui/suite/componentContextMenu.ts b/test/ui/suite/componentContextMenu.ts index 2f7b6fb07..86603c39d 100644 --- a/test/ui/suite/componentContextMenu.ts +++ b/test/ui/suite/componentContextMenu.ts @@ -59,7 +59,7 @@ export function testComponentContextMenu() { it('Start Dev works', async function () { this.timeout(60_000); - await stabilizeComponentsView(getSection); + await waitForItemStable(getSection, componentName, true); //start dev await startDev(true); @@ -237,21 +237,24 @@ export function testComponentContextMenu() { //open debug console view const bottomBar = new BottomBarPanel(); - await bottomBar.openDebugConsoleView(); + const debugConsole = await bottomBar.openDebugConsoleView(); //wait for console to have text - await new Promise((res) => setTimeout(res, 1_000)); + // await new Promise((res) => setTimeout(res, 1_000)); const bottomBarText = await bottomBar.getDriver().wait( - async () => { - const text = await bottomBar.getText(); - if (text.length !== 0) { - return text; - } - }, - 10_000, - 'No text in debug console found in 10 seconds', + async () => { + const text = await debugConsole.getText(); + + if (text && text.includes('App started on PORT')) { + return text; + } + + return null; + }, + 20_000, + 'Debug console did not contain expected output' ); - //const bottomBarText = await bottomBar.getText(); + expect(bottomBarText).to.contain('App started on PORT'); //Check side bar view has been switched from openshift to run and debug @@ -326,7 +329,8 @@ export function testComponentContextMenu() { const items = await menu.getItems(); for (const item of items) { - if ((await item.getLabel()) === option) { + const itemLabel = await item.getLabel(); + if (itemLabel === option) { try { await item.safeClick(); return true; diff --git a/test/ui/suite/createComponent.ts b/test/ui/suite/createComponent.ts index 449c8443c..c271dea59 100644 --- a/test/ui/suite/createComponent.ts +++ b/test/ui/suite/createComponent.ts @@ -126,10 +126,15 @@ export function testCreateComponent(path: string) { } await refreshView(); - await clickCreateComponent(); + + await step('Click Create Component', async () => { + await clickCreateComponent(); + }); const createCompView = await initializeEditor(); - await createCompView.createComponentFromLocalCodebase(); + await step('Click Create from Local Codebase', async () => { + await createCompView.createComponentFromLocalCodebase(); + }); const localCodeBasePage = new LocalCodeBasePage(); await localCodeBasePage.initializeEditor(); diff --git a/test/unit/index.ts b/test/unit/index.ts index 0a4c6ef8b..c01b4e15b 100644 --- a/test/unit/index.ts +++ b/test/unit/index.ts @@ -10,6 +10,8 @@ import * as paths from 'path'; import * as sourceMapSupport from 'source-map-support'; import { CoverageRunner, TestRunnerOptions } from '../coverage'; +/* eslint-disable no-console */ + sourceMapSupport.install(); const config: Mocha.MochaOptions = { @@ -71,6 +73,8 @@ export async function run(): Promise { let failed = 0; try { mocha.run(failures => { + console.log('Mocha reported failures:', failures); + if (failures > 0) { failed = failures; } @@ -89,7 +93,9 @@ export async function run(): Promise { }).catch((e) => { reject(e as Error); }) - }).on('fail', () => { + }).on('fail', (test, err) => { + console.error('Test failed:', test.fullTitle()); + console.error(err); failed++; }); } catch (e) { diff --git a/test/unit/openshift/component.test.ts b/test/unit/openshift/component.test.ts index 9f34b7ef3..3fe9ebfe7 100644 --- a/test/unit/openshift/component.test.ts +++ b/test/unit/openshift/component.test.ts @@ -16,7 +16,6 @@ import { DevfileInfo } from '../../../src/devfile-registry/devfileInfo'; import { DevfileRegistry } from '../../../src/devfile-registry/devfileRegistryWrapper'; import { Oc } from '../../../src/oc/ocWrapper'; import { Project } from '../../../src/oc/project'; -import { Command } from '../../../src/odo/command'; import { CommandProvider } from '../../../src/odo/componentTypeDescription'; import { Odo } from '../../../src/odo/odoWrapper'; import { ComponentWorkspaceFolder, OdoWorkspace } from '../../../src/odo/workspace'; @@ -26,13 +25,12 @@ import { Util as fsp } from '../../../src/util/utils'; import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal'; import { comp1Folder } from '../../fixtures'; - const { expect } = chai; chai.use(sinonChai); suite('OpenShift/Component', function () { let sandbox: sinon.SinonSandbox; - let termStub: sinon.SinonStub; let execStub: sinon.SinonStub; + let execStub: 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')); @@ -112,6 +110,7 @@ suite('OpenShift/Component', function () { postStart: [] } }, + commands: [], supportedOdoFeatures: { debug: true, deploy: true, @@ -119,6 +118,7 @@ suite('OpenShift/Component', function () { } }, runningIn: null, + runningOn: null, managedBy: 'odo', devForwardedPorts: [] } @@ -132,7 +132,6 @@ suite('OpenShift/Component', function () { Component = pq('../../../src/openshift/component', {}).Component; - termStub = sandbox.stub(OpenShiftTerminalManager.prototype, 'executeInTerminal'); execStub = sandbox.stub(Odo.prototype, 'execute').resolves({ stdout: '', stderr: undefined, error: undefined }); sandbox.stub(Oc.prototype, 'getProjects').resolves([projectItem]); sandbox.stub(Odo.prototype, 'describeComponent').resolves(componentItem1.component); @@ -256,11 +255,26 @@ suite('OpenShift/Component', function () { suite('describe', function() { + teardown(() => { + sinon.restore(); + }); + test('calls the correct odo command', async function () { + // As `odo describe` has removed, now we need to check the OpenShift terminal output + // instead of stubbing 'executeInTerminal' expecting the command to be invoked on the terminal + const compName = componentItem1.component.devfileData.devfile.metadata.name + + const writeStub = sinon.stub(); + sinon.stub(OpenShiftTerminalManager, 'getInstance').returns({ + writeToTerminal: writeStub + } as any); + await Component.describe(componentItem1); - expect(termStub).calledOnceWith(Command.describeComponent()); - }); + const output = writeStub.firstCall.args[0]; + expect(writeStub).calledOnce; + expect(output).to.contain(compName); + }); }); suite('component commands tree', function () { @@ -389,6 +403,7 @@ suite('OpenShift/Component', function () { }, }).Component as typeof openShiftComponent.Component; } + test('starts java debugger for devfile component with java in builder image', async () => { const startDebugging = sandbox.stub().resolves(true); Component = mockComponent(startDebugging); diff --git a/tsconfig.json b/tsconfig.json index ba0e39282..737c1c247 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "module": "CommonJS", "esModuleInterop": true, "strict": false, - "noUnusedLocals": true, + "noUnusedLocals": false, "resolveJsonModule": true, "moduleResolution": "node", "experimentalDecorators": true,