diff --git a/build/esbuild.settings.cjs b/build/esbuild.settings.cjs index 6f0ef0acb..234fbb289 100644 --- a/build/esbuild.settings.cjs +++ b/build/esbuild.settings.cjs @@ -17,6 +17,7 @@ const webviews = [ 'feedback', 'serverless-function', 'serverless-manage-repository', + 'add-service-binding', 'openshift-terminal', ]; diff --git a/package.json b/package.json index 6afb1fb61..b59389a78 100644 --- a/package.json +++ b/package.json @@ -541,6 +541,11 @@ "title": "Start Dev (manually trigger rebuild)", "category": "OpenShift" }, + { + "command": "openshift.component.binding.add", + "title": "Bind Service", + "category": "OpenShift" + }, { "command": "openshift.component.exitDevMode", "title": "Stop Dev", @@ -1382,6 +1387,10 @@ "command": "openshift.component.openInBrowser", "when": "false" }, + { + "command": "openshift.component.binding.add", + "when": "false" + }, { "command": "openshift.Serverless.openFunction", "when": "false" @@ -1846,6 +1855,11 @@ "when": "view == openshiftComponentsView && viewItem =~ /openshift\\.component.*\\.dep-nrn.*/ || viewItem =~ /openshift\\.component.*\\.dep-run.*/", "group": "c2@1" }, + { + "command": "openshift.component.binding.add", + "when": "view == openshiftComponentsView && viewItem =~ /openshift\\.component.*\\.dep-nrn.*/ || viewItem =~ /openshift\\.component.*\\.dep-run.*/", + "group": "c2@2" + }, { "command": "openshift.component.showDevTerminal", "when": "view == openshiftComponentsView && viewItem =~ /openshift\\.component.*\\.dev-run.*/", diff --git a/src/k8s/servicebinding/bindableService.ts b/src/k8s/servicebinding/bindableService.ts new file mode 100644 index 000000000..24e620934 --- /dev/null +++ b/src/k8s/servicebinding/bindableService.ts @@ -0,0 +1,452 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { Oc } from '../../oc/ocWrapper'; +import { basename } from 'path'; +import { + BindableKinds, + RestMapping, + ServiceBindingReference, + ServiceBindingResource, + WorkloadReference, +} from './bindableTypes'; +import { KubeConfigInfo } from '../../util/kubeUtils'; +import { + ApiextensionsV1Api, + CustomObjectsApi, + KubernetesObject, + CoreV1Api, + AppsV1Api, + V1OwnerReference, + V1CustomResourceDefinition, +} from '@kubernetes/client-node'; + +export class BindableService { + private static INSTANCE = new BindableService(); + + static get Instance() { + return BindableService.INSTANCE; + } + + private readonly kubeConfigInfo = new KubeConfigInfo(); + private readonly kc = this.kubeConfigInfo.getEffectiveKubeConfig(); + + /** + * Returns the CustomObjectsApi client for interacting with custom resources in the Kubernetes clusters + * @returns the CustomObjectsApi client for interacting with custom resources in the Kubernetes cluster + */ + private getCustomObjectsClient(): CustomObjectsApi { + return this.kc.makeApiClient(CustomObjectsApi); + } + + /** + * Returns the current namespace from the kubeconfig, or 'default' if not set + * @returns the current namespace from the kubeconfig, or 'default' if not set + */ + private getCurrentNamespace(): string { + const currentContext = this.kubeConfigInfo.findContext(this.kc.currentContext); + return currentContext.namespace ?? 'default'; + } + + /** + * Returns the list of bindable services available in the cluster, or an empty list if none are found. + * @returns the list of bindable services available in the cluster, or an empty list if none are found + */ + public async getBindableServices(): Promise { + const bindableKinds = await Oc.Instance.getBindableKinds(); + + if (!bindableKinds.status?.length) { + return []; + } + + const mappings = await this.getBindableKindRestMappings(bindableKinds); + + if (!mappings.length) { + return []; + } + + return this.listDynamicResources(mappings); + } + + /** + * Returns the list of REST mappings for the given bindable kinds. + * @param bindableKinds The bindable kinds to map. + * @returns The list of REST mappings for the given bindable kinds. + */ + private async getBindableKindRestMappings( + bindableKinds: BindableKinds, + ): Promise { + const mappings: RestMapping[] = []; + const visited = new Set(); + + for (const bindableKind of bindableKinds.status ?? []) { + const key = `${bindableKind.group}/${bindableKind.kind}`; + + if (visited.has(key)) { + continue; + } + visited.add(key); + + try { + const apiResourceList = await Oc.Instance.getApiResourceList( + bindableKind.group, + bindableKind.version, + ); + + const resources = apiResourceList.resources ?? []; + + const apiResource = resources.find( + (resource) => resource.kind === bindableKind.kind, + ); + + if (!apiResource) { + continue; + } + + mappings.push({ + group: bindableKind.group, + version: bindableKind.version, + kind: bindableKind.kind, + resource: apiResource.name, + }); + } catch (error) { + return []; + } + } + + return mappings; + } + + /** + * Returns the list of dynamic resources for the bindable kinds. + * @param mappings The list of REST mappings for the bindable kinds. + * @returns The list of dynamic resources for the bindable kinds. + */ + private async listDynamicResources(mappings: RestMapping[]): Promise { + const api = this.getCustomObjectsClient(); + const namespace = this.getCurrentNamespace(); + + const resources: KubernetesObject[] = []; + + for (const mapping of mappings) { + try { + const response = (await api.listNamespacedCustomObject({ + group: mapping.group, + version: mapping.version, + namespace, + plural: mapping.resource, + })) as { items?: KubernetesObject[] }; + + if (response.items?.length) { + resources.push(...response.items); + } + } catch (error) { + continue; + } + } + + return resources; + } + + /** + * Builds a ServiceBinding resource. + * @param apiVersion The API version of the service binding. + * @param bindingName The name of the service binding. + * @param namespace The namespace of the service binding. + * @param application The application reference for the service binding. + * @param service The service reference for the service binding. + * @returns The constructed ServiceBinding resource. + */ + private static build( + apiVersion: string, + bindingName: string, + namespace: string, + application: ServiceBindingReference, + service: ServiceBindingReference, + ): KubernetesObject { + return { + apiVersion, + kind: 'ServiceBinding', + metadata: { + name: bindingName, + namespace, + }, + spec: { + application, + services: [service], + detectBindingResources: true, + bindAsFiles: true, + }, + }; + } + + /** + * Adds a service binding to the specified context. + * @param contextPath The path to the context where the binding should be added. + * @param selectedServiceObject The service object to bind to. + * @param bindingName The name of the binding. + */ + public async addBinding( + contextPath: string, + selectedServiceObject: KubernetesObject, + bindingName: string, + ): Promise { + const componentName = basename(contextPath); + const namespace = this.getCurrentNamespace(); + + const workload = await this.getWorkloadByComponent(componentName); + + if ( + !selectedServiceObject.apiVersion || + !selectedServiceObject.kind || + !selectedServiceObject.metadata?.name || + !selectedServiceObject.metadata?.namespace + ) { + throw new Error('Selected service object is invalid.'); + } + + const applicationApi = this.parseApiVersion(workload.apiVersion); + const serviceApi = this.parseApiVersion(selectedServiceObject.apiVersion); + + const application: ServiceBindingReference = { + group: applicationApi.group, + version: applicationApi.version, + kind: workload.kind, + resource: workload.resource, + name: workload.name, + }; + + const service: ServiceBindingReference = { + group: serviceApi.group, + version: serviceApi.version, + kind: selectedServiceObject.kind, + name: selectedServiceObject.metadata.name, + namespace: selectedServiceObject.metadata.namespace, + }; + const serviceBindingResource = await this.getServiceBindingResource(); + + if (!serviceBindingResource) { + throw new Error('Failed to retrieve ServiceBinding resource definition.'); + } + + const serviceBinding = BindableService.build( + `${serviceBindingResource.group}/${serviceBindingResource.version}`, + bindingName, + namespace, + application, + service, + ); + + await this.createNamespacedCustomObject( + serviceBindingResource.group, + serviceBindingResource.version, + namespace, + serviceBindingResource.plural, + serviceBinding, + ); + } + + /** + * Parses the API version string into its group and version components. + * @param apiVersion The API version string to parse. + * @returns An object containing the group and version. + */ + private parseApiVersion(apiVersion: string): { group: string; version: string } { + const parts = apiVersion.split('/'); + + return parts.length === 2 + ? { group: parts[0], version: parts[1] } + : { group: '', version: parts[0] }; + } + + /** + * Returns the ServiceBinding resource definition, including group, version, and plural. + * @returns The ServiceBinding resource definition, including group, version, and plural. + * @throws Error if the ServiceBinding CRD is not found or has no served version. + */ + private async getServiceBindingResource(): Promise { + try { + const api: ApiextensionsV1Api = this.kc.makeApiClient(ApiextensionsV1Api); + + const response = await api.listCustomResourceDefinition(); + + if (!response.items?.length) { + throw new Error('No CustomResourceDefinitions found.'); + } + + const crd = response.items.find( + (item: V1CustomResourceDefinition) => item.spec?.names?.kind === 'ServiceBinding', + ); + + if (!crd) { + throw new Error('ServiceBinding CustomResourceDefinition not found.'); + } + + const version = + crd.spec?.versions?.find((v) => v.storage)?.name ?? + crd.spec?.versions?.find((v) => v.served)?.name; + + if (!version) { + throw new Error( + `No served or storage version found for CRD '${crd.metadata?.name ?? 'ServiceBinding'}'.`, + ); + } + + const group = crd.spec?.group; + const plural = crd.spec?.names?.plural; + + if (!group || !plural) { + throw new Error( + `CRD '${crd.metadata?.name ?? 'ServiceBinding'}' is missing required metadata.`, + ); + } + + return { + group, + version, + plural, + }; + } catch (error) { + throw new Error( + `Failed to retrieve ServiceBinding resource definition: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + /** + * Builds a ServiceBinding resource. + * @param group The group of the service binding. + * @param version The version of the service binding. + * @param namespace The namespace of the service binding. + * @param plural The plural name of the service binding. + * @param body The body of the service binding. + * @returns The constructed ServiceBinding resource. + */ + private async createNamespacedCustomObject( + group: string, + version: string, + namespace: string, + plural: string, + body: KubernetesObject, + ): Promise { + const api = this.getCustomObjectsClient(); + + try { + await api.createNamespacedCustomObject({ + group, + version, + namespace, + plural, + body, + }); + } catch (error) { + throw new Error( + `Failed to create custom resource: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + /** + * Retrieves the workload reference for a specific component. + * @param componentName The name of the component for which to retrieve the workload reference. + * @returns The workload reference for the specified component. + */ + private async getWorkloadByComponent(componentName: string): Promise { + const namespace = this.getCurrentNamespace(); + + const coreApi: CoreV1Api = this.kc.makeApiClient(CoreV1Api); + const appsApi: AppsV1Api = this.kc.makeApiClient(AppsV1Api); + + // Find the pod created for the component + const podList = await coreApi.listNamespacedPod({ + namespace, + labelSelector: `component=${componentName}`, + }); + + if (!podList.items.length) { + throw new Error(`No pod found for component '${componentName}'.`); + } + + const pod = podList.items[0]; + + const owner = pod.metadata?.ownerReferences?.find((ref) => ref.controller); + + if (!owner) { + throw new Error(`Pod '${pod.metadata?.name}' has no controller owner.`); + } + + switch (owner.kind) { + case 'StatefulSet': + case 'DaemonSet': + case 'Job': + return this.createWorkloadReference(owner); + + case 'ReplicaSet': { + const replicaSet = await appsApi.readNamespacedReplicaSet({ + name: owner.name, + namespace, + }); + + const deploymentOwner = replicaSet.metadata?.ownerReferences?.find( + (ref) => ref.controller, + ); + + if (!deploymentOwner) { + throw new Error(`ReplicaSet '${owner.name}' has no controller owner.`); + } + + return this.createWorkloadReference(deploymentOwner); + } + + default: + throw new Error(`Unsupported workload owner kind '${owner.kind}'.`); + } + } + + /** + * Creates a workload reference from the owner reference. + * @param owner The owner reference of the workload. + * @returns The created workload reference. + */ + private createWorkloadReference(owner: V1OwnerReference): WorkloadReference { + return { + apiVersion: owner.apiVersion, + kind: owner.kind, + resource: this.getResourceName(owner.kind), + name: owner.name, + }; + } + + /** + * Retrieves the resource name for a specific workload kind. + * @param kind The kind of the workload (e.g., Deployment, StatefulSet, DaemonSet, Job, ReplicaSet). + * @returns The resource name for the specified workload kind. + */ + private getResourceName(kind: string): string { + switch (kind) { + case 'Deployment': + return 'deployments'; + + case 'StatefulSet': + return 'statefulsets'; + + case 'DaemonSet': + return 'daemonsets'; + + case 'Job': + return 'jobs'; + + case 'ReplicaSet': + return 'replicasets'; + + default: + throw new Error(`Unsupported workload kind '${kind}'.`); + } + } +} diff --git a/src/k8s/servicebinding/bindableTypes.ts b/src/k8s/servicebinding/bindableTypes.ts new file mode 100644 index 000000000..ef6db4344 --- /dev/null +++ b/src/k8s/servicebinding/bindableTypes.ts @@ -0,0 +1,69 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ +export interface BindableKindStatus { + group: string; + version: string; + kind: string; +} + +export interface BindableKinds { + status: BindableKindStatus[]; +} + +export interface RestMapping { + group: string; + version: string; + kind: string; + resource: string; +} + +export interface APIResource { + name: string; + kind: string; +} + +export interface APIResourceList { + resources: APIResource[]; +} + +export interface ServiceBindingReference { + group?: string; + version?: string; + kind?: string; + resource?: string; + namespace?: string; + name: string; +} + +export interface ServiceBindingSpec { + application: ServiceBindingReference; + services: ServiceBindingReference[]; + detectBindingResources: boolean; + bindAsFiles: boolean; + namingStrategy?: string; +} + +export interface ServiceBinding { + apiVersion: 'binding.operators.coreos.com/v1alpha1'; + kind: 'ServiceBinding'; + metadata: { + name: string; + namespace: string; + }; + spec: ServiceBindingSpec; +} + +export interface WorkloadReference { + apiVersion: string; + kind: string; + resource: string; + name: string; +} + +export interface ServiceBindingResource { + group: string; + version: string; + plural: string; +} diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index 11c883c65..37d53ba26 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -17,6 +17,7 @@ import { ExecutionContext } from '../util/utils'; import { findDevfiles, getComponentName } from './devfileUtils'; import { Project } from './project'; import { ClusterType, KubernetesConsole } from './types'; +import { APIResourceList, BindableKinds } from '../k8s/servicebinding/bindableTypes'; /** * A wrapper around the `oc` CLI tool. @@ -1133,6 +1134,78 @@ export class Oc { await this.deleteOdoFiles(componentPath, componentName); } + /** + * Returns the list of bindable kinds available in the cluster. + * @returns the list of bindable kinds available in the cluster, or an empty list if none are found + */ + public async getBindableKinds(): Promise { + try { + const result = await CliChannel.getInstance().executeTool( + new CommandText( + 'oc', + 'get bindablekinds.binding.operators.coreos.com bindable-kinds', + [new CommandOption('-o', 'json')], + ), + ); + + const bindableKinds = JSON.parse(result.stdout) as BindableKinds; + + if (!bindableKinds.status) { + return { status: [] }; + } + + return bindableKinds; + } catch (error) { + if (error instanceof Error) { + return { status: [] }; + } + + throw error; + } + } + + public async getApiResourceList(group: string, version: string): Promise { + try { + // Call /apis/{group}/{version} and get the JSON + const result = await CliChannel.getInstance().executeTool( + new CommandText( + 'oc', + `get --raw /apis/${group}/${version}`, + ), + ); + + return JSON.parse(result.stdout) as APIResourceList; + } catch (error) { + if (error instanceof Error) { + return { resources: [] }; + } + + throw error; + } + } + + public async addBinding(contextPath: string, namespace: any, name: any, bindingName: string): Promise { + try { + await CliChannel.getInstance().executeTool( + new CommandText( + 'oc', + 'create servicebinding', + [ + new CommandOption('-n', namespace), + new CommandOption(bindingName), + new CommandOption('--from', name), + ], + ), + ); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to create service binding: ${error.message}`); + } + + throw error; + } + } + public async getComponentPod(componentName: string): Promise { const selectors = [ diff --git a/src/openshift/component.ts b/src/openshift/component.ts index e254a62cd..c1aca316f 100644 --- a/src/openshift/component.ts +++ b/src/openshift/component.ts @@ -23,6 +23,11 @@ import OpenShiftItem, { clusterRequired, projectRequired } from './openshiftItem import { DevfileCommandRunner } from '../devfile/devfileCommandRunner'; import { deployComponent } from '../devfile/deploy'; import { undeployComponent } from '../devfile/undeploy'; +import { KubernetesObject } from '@kubernetes/client-node'; +import { BindableService } from '../k8s/servicebinding/bindableService'; +import sendTelemetry from '../telemetry'; +import { ServiceBindingFormResponse } from '../webview/serverless-function/serverlessFunctionLoader'; +import AddServiceBindingViewLoader from '../webview/add-service-binding/addServiceBindingLoader'; function createStartDebuggerResult(language: string, message = '') { const result: any = new String(message); @@ -247,6 +252,97 @@ export class Component extends OpenShiftItem { return false; } + @vsCommand('openshift.component.binding.add') + static async addBinding(component: ComponentWorkspaceFolder) { + let bindableServices: KubernetesObject[] = []; + await Progress.execFunctionWithProgress('Getting bindable services', async () => { + bindableServices = await BindableService.Instance.getBindableServices(); + }).then(() => { + if (bindableServices.length === 0) { + void window + .showErrorMessage( + 'No bindable services are available', + 'Open Service Catalog in OpenShift Console', + ) + .then((result) => { + if (result === 'Open Service Catalog in OpenShift Console') { + void commands.executeCommand( + 'openshift.open.operatorBackedServiceCatalog', + ); + } + }); + return; + } + }); + + void sendTelemetry('startAddBindingWizard'); + + let formResponse: ServiceBindingFormResponse = undefined; + try { + formResponse = await new Promise( + (resolve, reject) => { + void AddServiceBindingViewLoader.loadView( + component.contextPath, + bindableServices.map( + (service) => `${service.metadata.namespace}/${service.metadata.name}`, + ), + (panel) => { + panel.onDidDispose((_e) => { + reject(new Error('The \'Add Service Binding\' wizard was closed')); + }); + return async (eventData) => { + if (eventData.action === 'addServiceBinding') { + resolve(eventData.params); + await panel.dispose(); + } + }; + }, + ).then(view => { + if (!view) { + // the view was already created + reject(undefined as Error); + } + }); + }, + ); + } catch { + // The form was closed without submitting, + // or the form already exists for this component. + // stop the command. + return; + } + + const selectedServiceObject = bindableServices.filter( + (service) => + `${service.metadata.namespace}/${service.metadata.name}` === formResponse.selectedService, + )[0]; + + void sendTelemetry('finishAddBindingWizard'); + + await Progress.execFunctionWithProgress( + `Adding binding service ${formResponse.bindingName}`, + async () => { + await BindableService.Instance.addBinding( + component.contextPath, + selectedServiceObject, + formResponse.bindingName, + ); + }, + ) + .then(() => { + void window.showInformationMessage( + `Service binding '${formResponse.bindingName}' was created successfully.`, + ); + }) + .catch((error) => { + void window.showErrorMessage( + `Failed to create service binding: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + } + @vsCommand('openshift.component.dev') @clusterRequired() @projectRequired() diff --git a/src/webview/add-service-binding/addServiceBindingLoader.ts b/src/webview/add-service-binding/addServiceBindingLoader.ts new file mode 100644 index 000000000..11d37fb04 --- /dev/null +++ b/src/webview/add-service-binding/addServiceBindingLoader.ts @@ -0,0 +1,107 @@ +/*----------------------------------------------------------------------------------------------- + * 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 path from 'path'; +import * as vscode from 'vscode'; +import { Odo } from '../../odo/odoWrapper'; +import { ExtensionID } from '../../util/constants'; +import { loadWebviewHtml } from '../common-ext/utils'; + +export interface ServiceBindingFormResponse { + selectedService: string; + bindingName: string; +} + +export default class AddServiceBindingViewLoader { + + private static views: Map = new Map(); + + private static get extensionPath(): string { + return vscode.extensions.getExtension(ExtensionID).extensionPath; + } + + /** + * Returns a webview panel with the "Add Service Binding" UI, + * or if there is an existing view for the given contextPath, focuses that view and returns null. + * + * @param contextPath the path to the component that's being binded to a service + * @param availableServices the list of all bindable services on the cluster + * @param listenerFactory the listener function to receive and process messages from the webview + * @returns the webview as a promise + */ + static async loadView( + contextPath: string, + availableServices: string[], + listenerFactory: (panel: vscode.WebviewPanel) => (event) => Promise, + ): Promise { + + if (AddServiceBindingViewLoader.views.get(contextPath)) { + // the event handling for the panel should already be set up, + // no need to handle it + const panel = AddServiceBindingViewLoader.views.get(contextPath); + panel.reveal(vscode.ViewColumn.One); + return null; + } + + return this.createView(contextPath, availableServices, listenerFactory); + } + + private static async createView( + contextPath: string, + availableServices: string[], + listenerFactory: (panel: vscode.WebviewPanel) => (event) => Promise, + ): Promise { + const localResourceRoot = vscode.Uri.file( + path.join(AddServiceBindingViewLoader.extensionPath, 'out', 'add-service-binding', 'app'), + ); + + let panel: vscode.WebviewPanel = vscode.window.createWebviewPanel( + 'addServiceBindingView', + 'Add service binding', + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [localResourceRoot], + retainContextWhenHidden: true, + }, + ); + + panel.iconPath = vscode.Uri.file( + path.join(AddServiceBindingViewLoader.extensionPath, 'images/context/cluster-node.png'), + ); + panel.webview.html = await loadWebviewHtml( + 'add-service-binding', + panel, + ); + panel.webview.onDidReceiveMessage(listenerFactory(panel)); + + // set theme + void panel.webview.postMessage({ + action: 'setTheme', + themeValue: vscode.window.activeColorTheme.kind, + }); + const colorThemeDisposable = vscode.window.onDidChangeActiveColorTheme(async function (colorTheme: vscode.ColorTheme) { + await panel.webview.postMessage({ action: 'setTheme', themeValue: colorTheme.kind }); + }); + + panel.onDidDispose(() => { + colorThemeDisposable.dispose(); + panel = undefined; + AddServiceBindingViewLoader.views.delete(contextPath); + }); + AddServiceBindingViewLoader.views.set(contextPath, panel); + + // send initial data to panel + void panel.webview.postMessage({ + action: 'setAvailableServices', + availableServices, + }); + void panel.webview.postMessage({ + action: 'setComponentName', + componentName: (await Odo.Instance.describeComponent(contextPath)).devfileData.devfile.metadata.name, + }); + + return Promise.resolve(panel); + } +} diff --git a/src/webview/add-service-binding/app/addServiceBindingForm.tsx b/src/webview/add-service-binding/app/addServiceBindingForm.tsx new file mode 100644 index 000000000..20c29f28f --- /dev/null +++ b/src/webview/add-service-binding/app/addServiceBindingForm.tsx @@ -0,0 +1,218 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { + Alert, + Box, + Button, + CircularProgress, + Container, + createTheme, + FormControl, + FormHelperText, + FormLabel, + Grid, + InputLabel, + MenuItem, + PaletteMode, + Select, + Stack, + TextField, + Theme, + ThemeProvider +} from '@mui/material'; +import * as React from 'react'; +import 'react-dom'; + +interface VSCodeMessage { + action: string; + themeValue?: number; + availableServices?: string[]; + componentName?: string; +} + +export function AddServiceBindingForm() { + // These are passed in after the component is created using the VS Code API + const [availableServices, setAvailableServices] = React.useState([]); + const [componentName, setComponentName] = React.useState(undefined); + + const [selectedService, setSelectedService] = React.useState(''); + const [bindingName, setBindingName] = React.useState(''); + + // Only mark a form field as an error after the user has first interacted with it + const [selectedServiceTouched, setSelectedServiceTouched] = React.useState(false); + const [bindingNameTouched, setBindingNameTouched] = React.useState(false); + + const createVscodeTheme = (paletteMode: PaletteMode): Theme => { + const computedStyle = window.getComputedStyle(document.body); + return createTheme({ + palette: { + mode: paletteMode, + primary: { + main: computedStyle.getPropertyValue('--vscode-button-background'), + }, + error: { + main: computedStyle.getPropertyValue('--vscode-editorError-foreground'), + }, + warning: { + main: computedStyle.getPropertyValue('--vscode-editorWarning-foreground'), + }, + info: { + main: computedStyle.getPropertyValue('--vscode-editorInfo-foreground'), + }, + success: { + main: computedStyle.getPropertyValue('--vscode-debugIcon-startForeground'), + }, + }, + typography: { + allVariants: { + fontFamily: computedStyle.getPropertyValue('--vscode-font-family'), + }, + }, + }); + }; + + const [theme, setTheme] = React.useState(createVscodeTheme('light')); + + const respondToMessage = function (message: MessageEvent) { + if (message.data.action === 'setTheme') { + setTheme(createVscodeTheme(message.data.themeValue === 1 ? 'light' : 'dark')); + } else if (message.data.action === 'setAvailableServices') { + setAvailableServices(message.data.availableServices); + } else if (message.data.action === 'setComponentName') { + setComponentName(message.data.componentName); + } + }; + + React.useEffect(() => { + window.addEventListener('message', respondToMessage); + return () => { + window.removeEventListener('message', respondToMessage); + }; + }, []); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + window.vscodeApi.postMessage({ + action: 'addServiceBinding', + params: { + selectedService, + bindingName, + }, + }); + }; + + const isBindingNameValid = (name: string): boolean => { + return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name); + }; + + return ( + + {availableServices && componentName ? ( + +
+ + + Bind Service to {componentName} + + + Service to Bind + + + The Operator-backed service to connect to your component + + + + { + if (!bindingNameTouched) { + setBindingNameTouched(true); + } + }} + onChange={(e) => { + setBindingName(e.target.value); + }} + error={bindingNameTouched && !isBindingNameValid(bindingName)} + required + > + + The name of the ServiceBinding Kubernetes resource. + Can only contain letters, numbers, and dashes (-). + + + + + {/* Instead of disabling the button when the form entries are invalid, + completely remove it, and instead show an alert explaining what needs to be fixed */} + {!isBindingNameValid(bindingName) || selectedService === '' ? ( + + You must  + {selectedService === '' && ( + <>select a service to bind to + )} + {!isBindingNameValid(bindingName) && + selectedService === '' && <> and } + {!isBindingNameValid(bindingName) && ( + <>set a valid binding name + )} + . + + ) : ( + + )} + + + + +
+
+ ) : ( + + + + )} +
+ ); +} diff --git a/src/webview/add-service-binding/app/index.html b/src/webview/add-service-binding/app/index.html new file mode 100644 index 000000000..54bac5bf2 --- /dev/null +++ b/src/webview/add-service-binding/app/index.html @@ -0,0 +1,30 @@ + + + + + + + Add Service Binding + + + + + +
+ + + diff --git a/src/webview/add-service-binding/app/index.tsx b/src/webview/add-service-binding/app/index.tsx new file mode 100644 index 000000000..6bcf4c236 --- /dev/null +++ b/src/webview/add-service-binding/app/index.tsx @@ -0,0 +1,16 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { createRoot } from 'react-dom/client'; +import * as React from 'react'; +import { AddServiceBindingForm } from './addServiceBindingForm'; +import { WebviewErrorBoundary } from '../../common/webviewErrorBoundary' + +const root = createRoot(document.getElementById('root')!); +root.render( + + + , +); diff --git a/src/webview/add-service-binding/app/tsconfig.json b/src/webview/add-service-binding/app/tsconfig.json new file mode 100644 index 000000000..9bcd883b7 --- /dev/null +++ b/src/webview/add-service-binding/app/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "target": "es6", + "outDir": "addServiceBindingView", + "lib": [ + "es6", + "dom" + ], + "jsx": "react", + "sourceMap": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "typeRoots": [ + "../../../../node_modules/@types", + "../../@types" + ], + "baseUrl": ".", + }, + "exclude": [ + "node_modules" + ] +} diff --git a/test/integration/ocWrapper.test.ts b/test/integration/ocWrapper.test.ts index 411bb7b18..24dbf1fec 100644 --- a/test/integration/ocWrapper.test.ts +++ b/test/integration/ocWrapper.test.ts @@ -398,4 +398,78 @@ suite('./oc/ocWrapper.ts', function () { expect(actual).to.equal(expected); }); + /*suite('Oc#getBindableServices', () => { + + teardown(() => sinon.restore()); + + test('returns empty when no bindable kinds exist', async () => { + sinon.stub(Oc.Instance as any, 'getBindableKinds') + .resolves({ status: [] }); + + const result = await Oc.Instance.getBindableServices(); + + expect(result).to.deep.equal([]); + }); + + test('returns resources', async () => { + sinon.stub(Oc.Instance as any, 'getBindableKinds') + .resolves({ + status: [ + { + group: 'postgresql.k8s.enterprisedb.io', + version: 'v1', + kind: 'Cluster' + } + ] + }); + + sinon.stub(Oc.Instance as any, 'getBindableKindRestMappings') + .resolves([ + { + group: 'postgresql.k8s.enterprisedb.io', + version: 'v1', + kind: 'Cluster', + resource: 'clusters' + } + ]); + + sinon.stub(Oc.Instance as any, 'listDynamicResources') + .resolves([ + { + apiVersion: 'postgresql.k8s.enterprisedb.io/v1', + kind: 'Cluster', + metadata: { + name: 'postgres' + } + } as any + ]); + + const result = await Oc.Instance.getBindableServices(); + + expect(result).to.have.length(1); + expect(result[0].metadata?.name).to.equal('postgres'); + }); + + test('returns empty when no REST mappings exist', async () => { + sinon.stub(Oc.Instance as any, 'getBindableKinds') + .resolves({ + status: [ + { + group: 'group', + version: 'v1', + kind: 'Kind' + } + ] + }); + + sinon.stub(Oc.Instance as any, 'getBindableKindRestMappings') + .resolves([]); + + const result = await Oc.Instance.getBindableServices(); + + expect(result).to.deep.equal([]); + }); + + });*/ + });