Skip to content

Commit baa68eb

Browse files
committed
Deploy container image to cluster
- Add "Create Deployment from Container Image URL" wizard to project context menu - It uses the VS Code text input - It has a back button and remembers what you've entered when you go back, similar to what Victor did for the login workflow - Once the deployment's been created, the logs for the container are opened in the OpenShift Terminal - Add demo gif and walkthrough entry for "Create Deployment from Container Image URL" - Add "Delete" context menu item for Kubernetes objects (eg. Deployments) - Add "Watch logs" context menu item for Kubernetes objects (eg. Deployments) Closes redhat-developer#2418 Signed-off-by: David Thompson <[email protected]>
1 parent 025aa38 commit baa68eb

File tree

7 files changed

+329
-5
lines changed

7 files changed

+329
-5
lines changed
6.49 MB
Loading

package.json

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@
364364
},
365365
{
366366
"command": "openshift.helm.openView",
367-
"title": "Open Helm View",
367+
"title": "Browse and Install Helm Charts",
368368
"category": "OpenShift"
369369
},
370370
{
@@ -755,11 +755,26 @@
755755
"title": "Create Operator-Backed Service",
756756
"category": "OpenShift"
757757
},
758+
{
759+
"command": "openshift.deployment.create.fromImageUrl",
760+
"title": "Create Deployment from Container Image URL",
761+
"category": "OpenShift"
762+
},
758763
{
759764
"command": "openshift.resource.load",
760765
"title": "Load",
761766
"category": "OpenShift"
762767
},
768+
{
769+
"command": "openshift.resource.delete",
770+
"title": "Delete",
771+
"category": "OpenShift"
772+
},
773+
{
774+
"command": "openshift.resource.watchLogs",
775+
"title": "Watch Logs",
776+
"category": "OpenShift"
777+
},
763778
{
764779
"command": "openshift.resource.unInstall",
765780
"title": "Uninstall",
@@ -944,7 +959,7 @@
944959
},
945960
{
946961
"id": "view/item/context/createService",
947-
"label": "Create Service"
962+
"label": "Create..."
948963
}
949964
],
950965
"viewsContainers": {
@@ -1242,6 +1257,14 @@
12421257
"command": "openshift.resource.load",
12431258
"when": "false"
12441259
},
1260+
{
1261+
"command": "openshift.resource.delete",
1262+
"when": "false"
1263+
},
1264+
{
1265+
"command": "openshift.resource.watchLogs",
1266+
"when": "false"
1267+
},
12451268
{
12461269
"command": "openshift.resource.unInstall",
12471270
"when": "false"
@@ -1494,6 +1517,11 @@
14941517
"command": "openshift.service.create",
14951518
"when": "view == openshiftProjectExplorer && isLoggedIn && viewItem =~ /openshift.project.*/i && showCreateService",
14961519
"group": "c2"
1520+
},
1521+
{
1522+
"command": "openshift.deployment.create.fromImageUrl",
1523+
"when": "view == openshiftProjectExplorer && isLoggedIn && viewItem =~ /openshift.project.*/i",
1524+
"group": "c2"
14971525
}
14981526
],
14991527
"view/item/context": [
@@ -1752,6 +1780,14 @@
17521780
"command": "openshift.resource.load",
17531781
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject || viewItem == openshift.k8sObject.route"
17541782
},
1783+
{
1784+
"command": "openshift.resource.delete",
1785+
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject || viewItem == openshift.k8sObject.route"
1786+
},
1787+
{
1788+
"command": "openshift.resource.watchLogs",
1789+
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject || viewItem == openshift.k8sObject.route"
1790+
},
17551791
{
17561792
"command": "openshift.resource.unInstall",
17571793
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject.helm"
@@ -1903,6 +1939,18 @@
19031939
"onCommand:openshift.helm.openView"
19041940
]
19051941
},
1942+
{
1943+
"id": "deployContainerImage",
1944+
"title": "Deploy Container Image from URL",
1945+
"description": "In the Application Explorer sidebar panel, expand the tree item corresponding to the OpenShift/Kubernetes cluster, then right click on the project, and select \"Create...\" > \"Create Deployment from Container Image URL\". You will be asked to enter the URL to the container image and enter a name for the deployment. Once you've submitted this information, the Deployment will be created, and the logs for the first container created as a part of the Deployment will be opened in the OpenShift Terminal.",
1946+
"media": {
1947+
"image": "images/walkthrough/deploy-a-container-image.gif",
1948+
"altText": "Creating a Deployment of the Docker Hub MongoDB container image called my-mongo-db using the steps from the description"
1949+
},
1950+
"completionEvents": [
1951+
"onCommand:openshift.deployment.create.fromImageUrl"
1952+
]
1953+
},
19061954
{
19071955
"id": "startDevComponent",
19081956
"title": "Start a component in development mode",

src/deployment.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
6+
import * as path from 'path';
7+
import validator from 'validator';
8+
import { Disposable, QuickInputButtons, ThemeIcon, TreeItem, window } from 'vscode';
9+
import { OpenShiftExplorer } from './explorer';
10+
import { Oc } from './oc/ocWrapper';
11+
import { validateRFC1123DNSLabel } from './openshift/nameValidator';
12+
import { quickBtn } from './util/inputValue';
13+
import { vsCommand } from './vscommand';
14+
15+
export class Deployment {
16+
17+
@vsCommand('openshift.deployment.create.fromImageUrl')
18+
static async createFromImageUrl(context: TreeItem): Promise<void> {
19+
20+
enum State {
21+
SelectImage, SelectName
22+
}
23+
24+
let state: State = State.SelectImage;
25+
let imageUrl: string;
26+
27+
while (state !== undefined) {
28+
29+
switch (state) {
30+
31+
case State.SelectImage: {
32+
33+
imageUrl = await Deployment.getImageUrl(false, imageUrl);
34+
35+
if (imageUrl === null || imageUrl === undefined) {
36+
return;
37+
}
38+
state = State.SelectName;
39+
break;
40+
}
41+
42+
case State.SelectName: {
43+
let cleanedUrl = imageUrl.startsWith('https://') ? imageUrl : `https://${imageUrl}`;
44+
if (cleanedUrl.lastIndexOf('/') > 0
45+
&& cleanedUrl.substring(cleanedUrl.lastIndexOf('/')).indexOf(':') >=0) {
46+
// it has a version tag, which we need to clean for the
47+
cleanedUrl = cleanedUrl.substring(0, cleanedUrl.lastIndexOf(':'));
48+
}
49+
const imageUrlAsUrl = new URL(cleanedUrl);
50+
const suggestedName = `my-${path.basename(imageUrlAsUrl.pathname)}`;
51+
52+
const deploymentName = await Deployment.getDeploymentName(suggestedName, true);
53+
54+
if (deploymentName === null) {
55+
return;
56+
} else if (deploymentName === undefined) {
57+
state = State.SelectImage;
58+
break;
59+
}
60+
61+
await Oc.Instance.createDeploymentFromImage(deploymentName, imageUrl);
62+
void window.showInformationMessage(`Created deployment '${deploymentName}' from image '${imageUrl}'`);
63+
OpenShiftExplorer.getInstance().refresh(context);
64+
65+
void OpenShiftExplorer.watchLogs({
66+
kind: 'Deployment',
67+
metadata: {
68+
name: deploymentName
69+
}
70+
});
71+
72+
return;
73+
}
74+
default:
75+
}
76+
77+
}
78+
79+
}
80+
81+
/**
82+
* Prompt the user for the URL of a container image.
83+
*
84+
* @returns the selected container image URL, or undefined if "back" was requested, and null if "cancel" was requested
85+
*/
86+
private static async getImageUrl(allowBack: boolean, initialValue?: string): Promise<string> {
87+
return new Promise<string | null | undefined>(resolve => {
88+
const disposables: Disposable[] = [];
89+
90+
const cancelBtn = new quickBtn(new ThemeIcon('close'), 'Cancel');
91+
92+
const inputBox = window.createInputBox();
93+
inputBox.placeholder = 'docker.io/library/mongo';
94+
inputBox.title = 'Image URL';
95+
inputBox.value = initialValue;
96+
if (allowBack) {
97+
inputBox.buttons = [QuickInputButtons.Back, cancelBtn];
98+
} else {
99+
inputBox.buttons = [cancelBtn];
100+
}
101+
inputBox.ignoreFocusOut = true;
102+
103+
disposables.push(inputBox.onDidHide(() => resolve(null)));
104+
105+
disposables.push(inputBox.onDidChangeValue((e) => {
106+
if (validator.isURL(inputBox.value)) {
107+
inputBox.validationMessage = undefined;
108+
} else {
109+
inputBox.validationMessage = 'Please enter a valid URL';
110+
}
111+
}));
112+
113+
disposables.push(inputBox.onDidAccept((e) => {
114+
resolve(inputBox.value);
115+
inputBox.hide();
116+
disposables.forEach(disposable => {disposable.dispose()});
117+
}));
118+
119+
disposables.push(inputBox.onDidTriggerButton((button) => {
120+
inputBox.hide();
121+
if (button === QuickInputButtons.Back) {
122+
resolve(undefined);
123+
} else if (button === cancelBtn) {
124+
resolve(null);
125+
}
126+
}));
127+
128+
inputBox.show();
129+
});
130+
}
131+
132+
/**
133+
* Prompt the user for the name of the deployment.
134+
*
135+
* @returns the selected deployment name, or undefined if "back" was requested, and null if "cancel" was requested
136+
*/
137+
private static async getDeploymentName(suggestedName: string, allowBack: boolean): Promise<string> {
138+
return new Promise<string | null | undefined>(resolve => {
139+
const disposables: Disposable[] = [];
140+
141+
const cancelBtn = new quickBtn(new ThemeIcon('close'), 'Cancel');
142+
143+
const inputBox = window.createInputBox();
144+
inputBox.placeholder = suggestedName;
145+
inputBox.value = suggestedName;
146+
inputBox.title = 'Deployment Name';
147+
if (allowBack) {
148+
inputBox.buttons = [QuickInputButtons.Back, cancelBtn];
149+
} else {
150+
inputBox.buttons = [cancelBtn];
151+
}
152+
inputBox.ignoreFocusOut = true;
153+
154+
disposables.push(inputBox.onDidHide(() => resolve(null)));
155+
156+
disposables.push(inputBox.onDidChangeValue((e) => {
157+
inputBox.validationMessage = validateRFC1123DNSLabel('Must be a valid Kubernetes name', inputBox.value);
158+
}));
159+
160+
disposables.push(inputBox.onDidAccept((e) => {
161+
resolve(inputBox.value);
162+
inputBox.hide();
163+
disposables.forEach(disposable => {disposable.dispose()});
164+
}));
165+
166+
disposables.push(inputBox.onDidTriggerButton((button) => {
167+
inputBox.hide();
168+
if (button === QuickInputButtons.Back) {
169+
resolve(undefined);
170+
} else if (button === cancelBtn) {
171+
resolve(null);
172+
}
173+
}));
174+
175+
inputBox.show();
176+
});
177+
}
178+
179+
}

src/explorer.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
version,
2222
window
2323
} from 'vscode';
24+
import { CommandText } from './base/command';
2425
import * as Helm from './helm/helm';
2526
import { HelmRepo } from './helm/helmChartType';
2627
import { Oc } from './oc/ocWrapper';
@@ -33,6 +34,7 @@ import { Progress } from './util/progress';
3334
import { FileContentChangeNotifier, WatchUtil } from './util/watch';
3435
import { vsCommand } from './vscommand';
3536
import { CustomResourceDefinitionStub } from './webview/common/createServiceTypes';
37+
import { OpenShiftTerminalManager } from './webview/openshift-terminal/openShiftTerminal';
3638

3739
type ExplorerItem = KubernetesObject | Helm.HelmRelease | Context | TreeItem | OpenShiftObject | HelmRepo;
3840

@@ -352,6 +354,35 @@ export class OpenShiftExplorer implements TreeDataProvider<ExplorerItem>, Dispos
352354
void commands.executeCommand('extension.vsKubernetesLoad', { namespace: component.metadata.namespace, kindName: `${component.kind}/${component.metadata.name}` });
353355
}
354356

357+
@vsCommand('openshift.resource.delete')
358+
public static async deleteResource(component: KubernetesObject) {
359+
await Oc.Instance.deleteKubernetesObject(component.kind, component.metadata.name);
360+
void window.showInformationMessage(`Deleted the '${component.kind}' named '${component.metadata.name}'`);
361+
OpenShiftExplorer.instance.refresh();
362+
}
363+
364+
@vsCommand('openshift.resource.watchLogs')
365+
public static async watchLogs(component: KubernetesObject) {
366+
// wait until logs are available before starting to stream them
367+
await Progress.execFunctionWithProgress(`Opening ${component.kind}/${component.metadata.name} logs...`, (_) => {
368+
return new Promise<void>(resolve => {
369+
370+
let intervalId: NodeJS.Timer = undefined;
371+
372+
function checkForPod() {
373+
void Oc.Instance.getLogs('Deployment', component.metadata.name).then((logs) => {
374+
clearInterval(intervalId);
375+
resolve();
376+
}).catch(_e => {});
377+
}
378+
379+
intervalId = setInterval(checkForPod, 200);
380+
});
381+
});
382+
383+
void OpenShiftTerminalManager.getInstance().createTerminal(new CommandText('oc', `logs -f ${component.kind}/${component.metadata.name}`), `Watching '${component.metadata.name}' logs`);
384+
}
385+
355386
@vsCommand('openshift.resource.unInstall')
356387
public static async unInstallHelmChart(release: Helm.HelmRelease) {
357388
return Progress.execFunctionWithProgress(`Uninstalling ${release.name}`, async () => {

src/extension.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export async function activate(extensionContext: ExtensionContext): Promise<unkn
9292
'./webview/devfile-registry/registryViewLoader',
9393
'./webview/helm-chart/helmChartLoader',
9494
'./webview/helm-manage-repository/manageRepositoryLoader',
95-
'./feedback'
95+
'./feedback',
96+
'./deployment'
9697
)),
9798
commands.registerCommand('clusters.openshift.useProject', (context) =>
9899
commands.executeCommand('extension.vsKubernetesUseNamespace', context),

src/oc/ocWrapper.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,33 @@ export class Oc {
410410
return false;
411411
}
412412

413+
/**
414+
* Creates a deployment with the given name from the given image URL.
415+
*
416+
* @param name the name of the deployment to create
417+
* @param imageUrl the url of the image to deploy
418+
*/
419+
public async createDeploymentFromImage(name: string, imageUrl: string): Promise<void> {
420+
await CliChannel.getInstance().executeTool(
421+
new CommandText('oc', `create deployment ${name}`, [new CommandOption('--image', imageUrl)])
422+
);
423+
}
424+
425+
/**
426+
* Returns the logs for the given resource.
427+
*
428+
* @param resourceType the type of resource to get the logs for
429+
* @param name the name of the resource to get the logs for
430+
* @throws if the logs are not available
431+
* @returns the logs for the given resource
432+
*/
433+
public async getLogs(resourceType: string, name: string): Promise<string> {
434+
const result = await CliChannel.getInstance().executeTool(
435+
new CommandText('oc', `logs ${resourceType}/${name}`)
436+
);
437+
return result.stdout;
438+
}
439+
413440
/**
414441
* Returns the oc command to list all resources of the given type in the given (or current) namespace
415442
*

0 commit comments

Comments
 (0)