Skip to content

Commit 0947c84

Browse files
committed
Add UI to create service binding
Right click on a component in the components view and select "Bind Service" in order to bind the component to an Operator-backed service. In order for this to work: - the service binding operator must be installed on the cluster - an operator that manages instances of services must be installed (I tested with the RHOAS operator) - there must be an instance of the service that is managed by the operator in the current project Other things in this PR: - Add a walkthrough GIF of using the UI - Add a smoke test to check that the "Bind Service" context menu item exists - Fix the smoke tests. This includes fixing the existing test case for #2780, even though we are going to rewrite that as a WebView UI soon. I did this, since for the "Bind Service" smoke test, we need a component present in the components view. Signed-off-by: David Thompson <davthomp@redhat.com>
1 parent fbacd3f commit 0947c84

File tree

19 files changed

+652
-43
lines changed

19 files changed

+652
-43
lines changed

.eslintignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ test-resources
99
src/webview/log
1010
src/webview/describe
1111
src/webview/cluster
12-
src/webview/webpack.config.js
1312
out
1413
src/webview/common
1514
src/webview/create-service

build/esbuild.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const webviews = [
1616
'helm-chart',
1717
'log',
1818
'welcome',
19-
'feedback'
19+
'feedback',
20+
'add-service-binding',
2021
];
2122

2223
function kebabToCamel(text) {
2.45 MB
Loading

package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,11 @@
462462
"title": "Start Dev",
463463
"category": "OpenShift"
464464
},
465+
{
466+
"command": "openshift.component.binding.add",
467+
"title": "Bind Service",
468+
"category": "OpenShift"
469+
},
465470
{
466471
"command": "openshift.component.exitDevMode",
467472
"title": "Stop Dev",
@@ -1081,6 +1086,10 @@
10811086
{
10821087
"command": "openshift.component.openInBrowser",
10831088
"when": "false"
1089+
},
1090+
{
1091+
"command": "openshift.component.binding.add",
1092+
"when": "false"
10841093
}
10851094
],
10861095
"view/title": [
@@ -1311,6 +1320,11 @@
13111320
"when": "view == openshiftComponentsView && viewItem =~ /openshift\\.component.*\\.dep-nrn.*/ || viewItem =~ /openshift\\.component.*\\.dep-run.*/",
13121321
"group": "c2@1"
13131322
},
1323+
{
1324+
"command": "openshift.component.binding.add",
1325+
"when": "view == openshiftComponentsView && viewItem =~ /openshift\\.component.*\\.dev-nrn.*/",
1326+
"group": "c2@2"
1327+
},
13141328
{
13151329
"command": "openshift.component.showDevTerminal",
13161330
"when": "view == openshiftComponentsView && viewItem =~ /openshift\\.component.*\\.dev-run.*/",

src/odo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export interface Odo {
8888
*/
8989
getActiveProject(): Promise<string>;
9090

91-
/*
91+
/**
9292
* Deletes all the odo configuration files associated with the component (`.odo`, `devfile.yaml`) located at the given path.
9393
*
9494
* @param componentPath the path to the component

src/odo/command.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,21 @@ export class Command {
311311
new CommandOption('-f'),
312312
]);
313313
}
314+
315+
static addBinding(serviceNamespace: string, serviceName: string, bindingName: string): CommandText {
316+
return new CommandText('odo add binding',
317+
undefined,
318+
[
319+
new CommandOption('--service-namespace', serviceNamespace, false),
320+
new CommandOption('--service', serviceName, false),
321+
new CommandOption('--name', bindingName, false),
322+
]
323+
)
324+
}
325+
326+
static getBindableServices(): CommandText {
327+
return new CommandText('odo list service',
328+
undefined,
329+
[new CommandOption('-o json')]);
330+
}
314331
}

src/odo3.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { KubernetesObject } from '@kubernetes/client-node';
77
import { CommandText } from './base/command';
8-
import { CliChannel } from './cli';
8+
import { CliChannel, CliExitData } from './cli';
99
import { Command as CommonCommand, loadItems } from './k8s/common';
1010
import { Command as DeploymentCommand } from './k8s/deployment';
1111
import { DeploymentConfig } from './k8s/deploymentConfig';
@@ -20,6 +20,30 @@ export interface Odo3 {
2020
setNamespace(newNamespace: string): Promise<void>;
2121

2222
describeComponent(contextPath: string): Promise<ComponentDescription | undefined>;
23+
24+
/**
25+
* Bind a component to a bindable service by modifying the devfile
26+
*
27+
* Resolves when the binding it created.
28+
*
29+
* @param contextPath the path to the component
30+
* @param serviceName the name of the service to bind to
31+
* @param serviceNamespace the namespace the the service is in
32+
* @param bindingName the name of the service binding
33+
*/
34+
addBinding(
35+
contextPath: string,
36+
serviceName: string,
37+
serviceNamespace: string,
38+
bindingName: string,
39+
): Promise<void>;
40+
41+
/**
42+
* Returns a list of all the bindable services on the cluster.
43+
*
44+
* @returns a list of all the bindable services on the cluster
45+
*/
46+
getBindableServices(): Promise<KubernetesObject[]>;
2347
}
2448

2549
export class Odo3Impl implements Odo3 {
@@ -69,6 +93,49 @@ export class Odo3Impl implements Odo3 {
6993
// ignore and return undefined
7094
}
7195
}
96+
97+
async addBinding(contextPath: string, serviceNamespace: string, serviceName: string, bindingName: string) {
98+
const myCommand = Command.addBinding(serviceNamespace, serviceName, bindingName);
99+
await CliChannel.getInstance().executeTool(
100+
myCommand,
101+
{cwd: contextPath},
102+
true
103+
);
104+
}
105+
106+
async getBindableServices(): Promise<KubernetesObject[]> {
107+
const data: CliExitData = await CliChannel.getInstance().executeTool(
108+
Command.getBindableServices()
109+
);
110+
let responseObj;
111+
try {
112+
responseObj = JSON.parse(data.stdout);
113+
} catch {
114+
throw new Error(JSON.parse(data.stderr).message);
115+
}
116+
if (!responseObj.bindableServices) {
117+
return [];
118+
}
119+
return (responseObj.bindableServices as BindableService[]) //
120+
.map(obj => {
121+
return {
122+
kind: obj.kind,
123+
apiVersion: obj.apiVersion,
124+
metadata: {
125+
namespace: obj.namespace,
126+
name: obj.name,
127+
}
128+
} as KubernetesObject;
129+
});
130+
}
131+
}
132+
133+
interface BindableService {
134+
name: string;
135+
namespace: string;
136+
kind: string;
137+
apiVersion: string;
138+
service: string;
72139
}
73140

74141
export function newInstance(): Odo3 {

src/openshift/component.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import { Command } from '../odo/command';
1515
import { ComponentTypeAdapter, ComponentTypeDescription, DevfileComponentType, ascDevfileFirst, isDevfileComponent } from '../odo/componentType';
1616
import { StarterProject, isStarterProject } from '../odo/componentTypeDescription';
1717
import { ComponentWorkspaceFolder } from '../odo/workspace';
18+
import * as odo3 from '../odo3';
1819
import { NewComponentCommandProps } from '../telemetry';
1920
import { Progress } from '../util/progress';
2021
import { selectWorkspaceFolder } from '../util/workspace';
2122
import { VsCommandError, vsCommand } from '../vscommand';
23+
import AddServiceBindingViewLoader, { ServiceBindingFormResponse } from '../webview/add-service-binding/addServiceBindingViewLoader';
2224
import GitImportLoader from '../webview/git-import/gitImportLoader';
2325
import LogViewLoader from '../webview/log/LogViewLoader';
2426
import OpenShiftItem from './openshiftItem';
@@ -202,6 +204,63 @@ export class Component extends OpenShiftItem {
202204
return Component.dev(component, 'podman');
203205
}
204206

207+
@vsCommand('openshift.component.binding.add')
208+
static async addBinding(component: ComponentWorkspaceFolder) {
209+
const odo: odo3.Odo3 = odo3.newInstance();
210+
211+
const services = await odo.getBindableServices();
212+
if (!services || services.length === 0) {
213+
throw new Error('No bindable services are available');
214+
}
215+
216+
let formResponse: ServiceBindingFormResponse = undefined;
217+
try {
218+
formResponse = await new Promise<ServiceBindingFormResponse>(
219+
(resolve, reject) => {
220+
void AddServiceBindingViewLoader.loadView(
221+
component.contextPath,
222+
services.map(
223+
(service) => `${service.metadata.namespace}/${service.metadata.name}`,
224+
),
225+
(panel) => {
226+
panel.onDidDispose((_e) => {
227+
reject(new Error('The \'Add Service Binding\' wizard was closed'));
228+
});
229+
return async (eventData) => {
230+
if (eventData.action === 'addServiceBinding') {
231+
resolve(eventData.params);
232+
await panel.dispose();
233+
}
234+
};
235+
},
236+
).then(view => {
237+
if (!view) {
238+
// the view was already created
239+
reject();
240+
}
241+
});
242+
},
243+
);
244+
} catch (e) {
245+
// The form was closed without submitting,
246+
// or the form already exists for this component.
247+
// stop the command.
248+
return;
249+
}
250+
251+
const selectedServiceObject = services.filter(
252+
(service) =>
253+
`${service.metadata.namespace}/${service.metadata.name}` === formResponse.selectedService,
254+
)[0];
255+
256+
await odo.addBinding(
257+
component.contextPath,
258+
selectedServiceObject.metadata.namespace,
259+
selectedServiceObject.metadata.name,
260+
formResponse.bindingName,
261+
);
262+
}
263+
205264
@vsCommand('openshift.component.dev')
206265
//@clusterRequired() check for user is logged in should be implemented from scratch
207266
static async dev(component: ComponentWorkspaceFolder, runOn?: undefined | 'podman') {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*-----------------------------------------------------------------------------------------------
2+
* Copyright (c) Red Hat, Inc. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
4+
*-----------------------------------------------------------------------------------------------*/
5+
import * as path from 'path';
6+
import * as vscode from 'vscode';
7+
import { ExtensionID } from '../../util/constants';
8+
import { loadWebviewHtml } from '../common-ext/utils';
9+
10+
export interface ServiceBindingFormResponse {
11+
selectedService: string;
12+
bindingName: string;
13+
}
14+
15+
export default class AddServiceBindingViewLoader {
16+
17+
private static views: Map<string, vscode.WebviewPanel> = new Map();
18+
19+
private static get extensionPath(): string {
20+
return vscode.extensions.getExtension(ExtensionID).extensionPath;
21+
}
22+
23+
/**
24+
* Returns a webview panel with the "Add Service Binding" UI,
25+
* or if there is an existing view for the given contextPath, focuses that view and returns null.
26+
*
27+
* @param contextPath the path to the component that's being binded to a service
28+
* @param availableServices the list of all bindable services on the cluster
29+
* @param listenerFactory the listener function to receive and process messages from the webview
30+
* @return the webview as a promise
31+
*/
32+
static async loadView(
33+
contextPath: string,
34+
availableServices: string[],
35+
listenerFactory: (panel: vscode.WebviewPanel) => (event) => Promise<void>,
36+
): Promise<vscode.WebviewPanel | null> {
37+
38+
if (AddServiceBindingViewLoader.views.get(contextPath)) {
39+
// the event handling for the panel should already be set up,
40+
// no need to handle it
41+
const panel = AddServiceBindingViewLoader.views.get(contextPath);
42+
panel.reveal(vscode.ViewColumn.One);
43+
return null;
44+
}
45+
46+
return this.createView(contextPath, availableServices, listenerFactory);
47+
}
48+
49+
private static async createView(
50+
contextPath: string,
51+
availableServices: string[],
52+
listenerFactory: (panel: vscode.WebviewPanel) => (event) => Promise<void>,
53+
): Promise<vscode.WebviewPanel> {
54+
const localResourceRoot = vscode.Uri.file(
55+
path.join(AddServiceBindingViewLoader.extensionPath, 'out', 'addServiceBindingViewer'),
56+
);
57+
58+
let panel: vscode.WebviewPanel = vscode.window.createWebviewPanel(
59+
'addServiceBindingView',
60+
'Add service binding',
61+
vscode.ViewColumn.One,
62+
{
63+
enableScripts: true,
64+
localResourceRoots: [localResourceRoot],
65+
retainContextWhenHidden: true,
66+
},
67+
);
68+
69+
panel.iconPath = vscode.Uri.file(
70+
path.join(AddServiceBindingViewLoader.extensionPath, 'images/context/cluster-node.png'),
71+
);
72+
panel.webview.html = await loadWebviewHtml(
73+
'addServiceBindingViewer',
74+
panel,
75+
);
76+
panel.webview.onDidReceiveMessage(listenerFactory(panel));
77+
78+
// set theme
79+
void panel.webview.postMessage({
80+
action: 'setTheme',
81+
themeValue: vscode.window.activeColorTheme.kind,
82+
});
83+
const colorThemeDisposable = vscode.window.onDidChangeActiveColorTheme(async function (colorTheme: vscode.ColorTheme) {
84+
await panel.webview.postMessage({ action: 'setTheme', themeValue: colorTheme.kind });
85+
});
86+
87+
panel.onDidDispose(() => {
88+
colorThemeDisposable.dispose();
89+
panel = undefined;
90+
AddServiceBindingViewLoader.views.delete(contextPath);
91+
});
92+
AddServiceBindingViewLoader.views.set(contextPath, panel);
93+
94+
// send initial data to panel
95+
void panel.webview.postMessage({
96+
action: 'setAvailableServices',
97+
availableServices,
98+
});
99+
void panel.webview.postMessage({
100+
action: 'setComponentName',
101+
componentName: path.basename(contextPath),
102+
});
103+
104+
return Promise.resolve(panel);
105+
}
106+
}

0 commit comments

Comments
 (0)